Queues
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.
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. QueueManager is a process-singleton that owns all queues and 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.
How a job runs
Section titled “How a job runs”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
Design
Section titled “Design”One QueueManager per process provides centralized lifecycle, shutdown, admin stats, and retry defaults. Per-queue directories keep constants, queue, worker, setup, and types together. When QUEUES_ENABLED=false, dev and tests boot without a worker process. Success jobs are retained for one hour or the latest 100 (removeOnComplete). Failed jobs remain inspectable; removeOnFail is false so the dashboard can show what broke. The BullMQ lint plugin catches missing failed handlers and retry config.
Queue anatomy
Section titled “Queue anatomy”Every queue is a small directory under src/queues/<name>/:
<name>.constants.ts: Queue name, job name, and retry defaults<name>.types.ts: JSON-serializable job-data type<name>.queue.ts: BullMQ Queue factory<name>.worker.ts: Worker plus structured-logged event handlers<name>.setup.ts: Boot wiring for queue and worker
The reference implementation is email-delivery (render a template, hand off to the email provider), so it’s a good copy-target.
QueueManager is the seam
Section titled “QueueManager is the seam”Application code never imports Queue directly; it calls manager.enqueueX(...). This provides one place to enforce retry/cleanup defaults across queues. Graceful shutdown works via manager.close(), which shuts every worker and queue in parallel; signal handlers only know about the manager. Admin observability comes from getStats(), which returns waiting/active/completed/failed/delayed/paused counts for every managed queue.
A new queue 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.
Idempotency
Section titled “Idempotency”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
INSERTof 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.
Adding a queue
Section titled “Adding a queue”- Create
src/queues/<name>/following the per-queue directory pattern (constants, types, queue, worker, setup). - Export setup and types through
src/queues/index.ts. Construct the pair insrc/config/setup/setup-queues.tsand pass it to the manager. - Extend
src/queues/queue-manager.ts: constructor input,enqueue<Name>(),getStats()andclose(). - Call
manager.enqueue<Name>(...)from the producer. Define durable retry idempotency; a stable job name is not a deduplication key. - Test enqueue options, repeated delivery, failure and shutdown. Any new configuration must be represented in env validation,
.env.example, Compose and the scaffold manifest.
Dashboard and monitoring
Section titled “Dashboard and monitoring”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.
Source
Section titled “Source”src/queues/ and src/config/setup-queues.ts wire the manager into boot.
Related
Section titled “Related”- Email; the producer side;
sendTemplate()goes throughemail-deliverywhen queues are on. - Lint as the contract; why machine-checked queue patterns matter.