BullMQ + Redis to Graphile Worker + PostgreSQL: What I Learned

Published: 2026-07-02

When I built the alert system for pulseticker, I reached for the obvious stack: BullMQ for the queue, Upstash Redis as the broker. Both are well-documented, widely used, and have generous free tiers — an easy, low-risk choice on paper.

Upstash’s free tier caps at 500,000 commands a month. I burned through it in a few days of development, which is not a sentence I expected to write about a hobby project’s alert system.

So here’s what actually happened: what went wrong, what I actually fixed, and why Graphile Worker turned out to be the right call anyway — for reasons that only became clear once I looked at how it works under the hood.


The Setup

pulseticker connects to Finnhub’s WebSocket feed and streams real-time price updates for a user’s watchlist. Users can set price alerts — “notify me when AAPL crosses $200” — and the system is supposed to fire a notification when a threshold is crossed.

The original pipeline looked like this:

Finnhub WebSocket

price.received event   (emitted per tick)
      ↓                          ↓
WebSocket broadcast      Alert check → BullMQ queue (job for every tick)

The WebSocket gateway and the alert-checking logic are separate listeners on the same event — deliberately decoupled so a slow alert check can never block real-time price delivery. The alert side is where the problem lived:

// alerts.service.ts — the original, naive pattern
@Injectable()
export class AlertsService {
  constructor(@InjectQueue('alerts') private alertQueue: Queue) {}

  @OnEvent('price.received')
  async handlePriceTick(payload: { symbol: string; price: number }) {
    await this.alertQueue.add('check-alert', payload);
  }
}

Every price tick enqueued a job, regardless of whether any alert was even registered for that symbol. Stocks tick dozens of times per second during market hours, and five symbols in a watchlist adds up to hundreds of queue writes per minute — within days, all 500,000 Upstash commands were gone.


The Wrong Diagnosis

My first instinct was that the problem was infrastructure: Upstash’s free tier is too small, so swap it out. I looked at alternatives — Redis on Fly.io, a self-hosted Redis container — and eventually landed on Graphile Worker, which uses the existing PostgreSQL database as its queue backend. No Redis needed at all.

That decision turned out to be correct — just not for the reason I thought. It wasn’t the fix.

The free tier ran out because I was writing a queue job for every single price tick, regardless of whether any alert condition was anywhere close to being triggered. Switching queue backends would not change that — with Graphile Worker pointing at Supabase’s PostgreSQL, I would have hammered the database just as hard. The actual fix was a pre-check.


The Actual Fix: Check Before You Enqueue

Before writing to the queue, the alerts service now checks an in-memory cache of active alerts for the incoming symbol. If no alerts exist for that symbol, or if no threshold is crossed, no job is created.

// alerts.service.ts — after the fix
@Injectable()
export class AlertsService {
  private cache = new Map<string, CachedAlert[]>(); // keyed by uppercase symbol

  constructor(private queueService: QueueService) {}

  @OnEvent('price.received')
  async checkAlerts(symbol: string, price: number) {
    const alerts = this.cache.get(symbol.toUpperCase()) ?? [];

    for (const alert of alerts) {
      const triggered =
        (alert.direction === 'above' && price >= alert.thresholdPrice) ||
        (alert.direction === 'below' && price <= alert.thresholdPrice);
      if (!triggered) continue;

      await this.queueService.addAlertCheckJob({
        alertId: alert.id,
        symbol: alert.symbol,
        price,
        userId: alert.userId
      });
    }
  }
}

The cache is loaded from Supabase on startup, then updated in place when a user creates or deletes an alert — no DB round-trip on the hot path. For most ticks — where no threshold is crossed — the function returns after a single in-memory lookup, and queue writes happen only when they need to. That’s what actually stopped the resource drain; the queue technology never mattered.


Why I Kept Graphile Worker Anyway

Even after identifying the real root cause, I did not go back to BullMQ + Redis. Graphile Worker was still the better choice — just for different reasons than I initially thought.

One less service. Graphile Worker stores jobs in a graphile_worker.jobs table inside your existing PostgreSQL database. The project was already using Supabase (PostgreSQL). Removing Redis meant one fewer managed service, one fewer connection string, one fewer free-tier quota to track. On a personal project running entirely on free tiers, that simplification has real value.

Schema migration included. Graphile Worker runs its own migrations on startup to create the tables it needs. There is nothing to configure.

The API is minimal:

// worker.ts — Graphile Worker setup
import { run } from 'graphile-worker';

const runner = await run({
  connectionString: process.env.DATABASE_URL,
  taskList: {
    'check-price-alert': async (payload: AlertJobPayload) => {
      // deactivate the alert, record it in alert_history,
      // then emit an event the gateway broadcasts over the existing WebSocket connection
      await processTriggeredAlert(payload);
    }
  }
});

Registering the job from the API side is just as small — jobKey scopes the job to a specific alert, and jobKeyMode: 'replace' means a second price tick for the same alert replaces the pending job instead of piling up a duplicate:

// Adding a job from NestJS — queue.service.ts
import { makeWorkerUtils } from 'graphile-worker';

const utils = await makeWorkerUtils({ connectionString: process.env.DATABASE_URL });
await utils.addJob('check-price-alert', payload, {
  jobKey: `alert-${payload.alertId}`,
  jobKeyMode: 'replace'
});

That is the entire integration surface. No connection pools to manage, no serialization options to configure, no Redis client to keep alive. Notification delivery itself stays on the existing Socket.io connection — no separate push notification service was ever in the picture.


The Part That Surprised Me: LISTEN/NOTIFY

I assumed Graphile Worker relied on a polling loop alone — check the jobs table every second or two, pick up whatever’s new. That’s a reasonable design, and it’s how plenty of Postgres-backed queues work.

That’s not the whole story.

Graphile Worker layers PostgreSQL’s native LISTEN/NOTIFY mechanism on top of polling, not instead of it. When addJob() inserts a row into graphile_worker.jobs, a trigger fires a NOTIFY on a dedicated channel, and any worker holding a LISTEN connection wakes up immediately — the official docs cite job pickup in the low single-digit milliseconds under normal conditions.

The poll loop (pollInterval, 2 seconds by default) keeps running underneath the whole time, as a fallback for when a notification gets dropped or a worker’s LISTEN connection blips. Either path ends the same way: a SKIP LOCKED query claims the job safely even with multiple workers running concurrently.

-- What happens inside Graphile Worker when you call addJob()
-- (simplified — the actual trigger is more involved)
NOTIFY "graphile_worker:jobs", '{}';

The practical effect is what I originally expected — near-instant pickup — just not for the reason I expected. It’s not “no polling,” it’s polling plus a push-based shortcut that makes the common case fast without giving up the fallback’s reliability. For alert notifications where a user expects near-instant feedback when a price threshold is crossed, that low-latency path is what matters.

This is also not a Graphile Worker invention — LISTEN/NOTIFY has been part of PostgreSQL since version 6.4. It is used for database-level pub/sub without any external broker. I had seen it mentioned before but never had a reason to look at how it actually works until this project.


When You Should Still Use Redis

Graphile Worker is not the right tool for everything. If your situation looks like any of the following, Redis is probably the better choice:

High throughput at scale. PostgreSQL handles job queues well up to a meaningful load, but a dedicated Redis instance will outperform it at high job volumes. If you are processing millions of jobs per hour, the database-as-queue trade-off shifts.

Fan-out / pub-sub patterns. BullMQ supports multiple consumers on the same queue, priority queues, rate limiting, and delayed jobs out of the box. Graphile Worker’s feature set is intentionally smaller.

You already have Redis. If Redis is already in your stack for caching or session storage, adding BullMQ costs almost nothing. The simplification argument for Graphile Worker only applies when Redis would otherwise be a new dependency.

Cross-database job sources. Graphile Worker jobs live in your PostgreSQL database. If your jobs are triggered by events in services that don’t share that database, the integration becomes awkward.

For pulseticker — a project where PostgreSQL was already the primary data store and Redis would have been added purely for the queue — Graphile Worker was the right call.


What Actually Fixed It

Switching from BullMQ + Redis to Graphile Worker removed a service from the stack and simplified deployment — a call I’d make again. What actually stopped the resource drain, though, was much smaller: four lines of cache logic in AlertsService, checking whether an alert existed before writing anything to the queue at all.

The queue was never the bottleneck. The job-creation logic was.


Further Reading

  • Graphile Worker — GitHub — the README covers the full API, caveats, and performance characteristics clearly. Worth reading before adopting it.
  • PostgreSQL LISTEN/NOTIFY docs — the official reference for the notification mechanism Graphile Worker builds on. Useful if you want to understand the delivery guarantees.
  • BullMQ docs — if your requirements go beyond what Graphile Worker offers, BullMQ’s documentation is thorough on advanced patterns like rate limiting, sandboxed processors, and flow producers.