Skip to content
BoringStack
GitHub

Queues

4 min read

Background work that can start inline

Background work uses BullMQ over the same Valkey the cache lives on. A single QueueManager owns queues and workers, so request handlers dispatch intent without knowing whether the work runs inline, locally, or in a worker.

BullMQ

job lifecycle

Valkey

queue backend

inline

fallback when queues are off

The mental model:

  • A queue is a named work buffer in Valkey.
  • A worker is a long-lived process that pulls jobs off the queue and runs them.
  • A QueueManager is a process-singleton that owns all queues + workers, so application code only ever talks to one object.

When QUEUES_ENABLED=false, every dispatch helper falls back to inline execution. Dev and tests run without a worker process.

sequenceDiagram
  participant Producer
  participant Manager as QueueManager
  participant Valkey
  participant Worker
  Producer->>Manager: enqueueX(data)
  Manager->>Valkey: ZADD with retry config
  Valkey-->>Worker: next job
  Worker->>Worker: process(data)
  alt success
    Worker->>Valkey: mark complete (TTL 1h)
  else failure
    Worker->>Valkey: schedule retry (exp backoff)
    Note over Valkey,Worker: up to 5 attempts, then dead-letter
  end
manager

One QueueManager per process

Centralized lifecycle, shutdown, admin stats, and retry defaults.

shape

Per-queue directory

Constants, queue, worker, setup, and types stay together.

dev

Inline fallback

When QUEUES_ENABLED=false, dev and tests boot without a worker process.

success

Bounded success retention

removeOnComplete keeps one hour or the latest 100 successes.

failure

Failed jobs remain inspectable

removeOnFail is false, so the dashboard can show what broke.

lint

Workers are checked

The BullMQ lint plugin catches missing failed handlers and retry config.

Every queue is a small directory under src/queues/<name>/:

Queue anatomy
src/queues/email-delivery/
  • email-delivery.constants.tsQueue name, job name, and retry defaults
  • email-delivery.types.tsJSON-serializable job-data type
  • email-delivery.queue.tsBullMQ Queue factory
  • email-delivery.worker.tsWorker plus structured-logged event handlers
  • email-delivery.setup.tsBoot wiring for queue + worker

The reference implementation is email-delivery; it’s the simplest worker that exists (render a template, hand off to the email provider), so it’s a good copy-target.

Application code never imports Queue directly. It calls manager.enqueueX(...). Why:

  • One place to enforce retry/cleanup defaults across queues.
  • Graceful shutdown: manager.close() shuts every worker + queue in parallel; signal handlers only know about the manager.
  • Admin observability: getStats() returns waiting/active/completed/failed/delayed/paused counts for every managed queue.

A new queue therefore needs four additions to QueueManager: the constructor input, an enqueue<Name>() method, an entry in getStats(), and a close() line. The lint plugin catches workers that omit a failed handler or skip retry config.

BullMQ retries on failure. If a worker dies after writing to the database but before marking the job complete, the same job runs again. Workers must be idempotent. Three patterns:

  • Natural keys. “Send verification email for userId=X, token=Y” is idempotent: re-sending the same token is harmless.
  • UNIQUE constraints plus catch. A second INSERT of the same audit row fails on the constraint; the worker treats unique-violation as success.
  • Check-then-do, transactional. Read state, decide if work is still needed, write atomically.

Using BullMQ’s jobId for deduplication only protects against simultaneous duplicate enqueues; not retries.

  1. Create src/queues/<name>/ following the per-queue directory pattern (constants, types, queue, worker, setup).
  2. Add it to QueueManager’s constructor, an enqueue<Name>() method, getStats(), and close().
  3. Call manager.enqueue<Name>(...) from the producer.

WITH_BULLMQ=1 in the infra stack brings up Bull-board at http://bullmq.localhost; jobs by state, retry timelines, manual retry/discard. Dev-only; not exposed in the prod profile. See Profiles & overlays.

@boring-stack-pkg/eslint-plugin-bullmq catches the common foot-guns: workers that don’t handle failed, jobs that mutate shared state without idempotency, missing retry config.

src/queues/ on GitHub. src/config/setup-queues.ts wires the manager into boot.

  • Email; the producer side; sendTemplate() goes through email-delivery when queues are on.
  • Lint as the contract; why machine-checked queue patterns matter.