Message Queues
Node.js Advanced
Chapter 6 of 8 · Message Queues
Message Queues
A message queue is a buffer that sits between a producer (the thing that generates work) and a consumer (the thing that does the work). The producer doesn't wait for the work to finish — it drops a message in the queue and moves on. The consumer picks it up whenever it's ready. This decoupling solves three problems at once: bursts of load can be absorbed without dropping requests, slow consumers don't block fast producers, and failed jobs can be retried automatically without any code in the original request path. In Node, the dominant queue stack is Redis-backed BullMQ — fast, durable, and richly featured.
Why Queue Instead of Awaiting Directly?
| Scenario | Direct await | Message queue |
|---|---|---|
| Send a welcome email on signup | User waits for email server; if it's down, signup fails | Signup completes instantly; email sent asynchronously with retries |
| Resize 50 uploaded images | Request handler blocks for seconds; client times out | Jobs queued immediately; workers process at their own pace |
| Traffic spike: 10,000 orders/minute | DB connection pool exhausted; requests fail | Queue absorbs the burst; workers drain at a controlled rate |
| External API call fails mid-processing | Work is lost; user must retry manually | Job stays in queue and retries with backoff automatically |
Core Vocabulary
Producer
The code that adds a job to the queue. Usually a request handler or another job. It doesn't know or care how many workers are consuming.
Consumer / Worker
The code that picks jobs off the queue and processes them. Can run in a separate process, on separate machines, or in parallel with many workers on the same queue.
Acknowledgement (ACK)
The worker tells the broker "I finished this job." Until the broker receives an ACK, it keeps the job in an "active" state so it can re-queue it if the worker crashes mid-job.
Dead Letter Queue (DLQ)
Where jobs land after exhausting all retries. They aren't deleted — they're parked so you can inspect them, fix the bug, and re-process them manually.
Pub/Sub
Fan-out pattern: one publisher sends a message to a channel; all subscribers receive a copy. Fire-and-forget — if a subscriber is offline, it misses the message.
Competing Consumers
Multiple workers reading from the same queue. Each job is delivered to exactly one worker. Scales horizontally — add workers to increase throughput.
Redis Pub/Sub — Lightweight Fan-Out
Redis Pub/Sub is the simplest messaging primitive: a publisher pushes a message to a named channel; every current subscriber receives it. There is no persistence — messages sent when no subscriber is listening are lost.
⚠️ Redis Pub/Sub Limitations
No persistence. If a subscriber is down or slow, messages are lost — there's no buffer. Use Redis Streams or BullMQ if you need durability or retry.
No acknowledgement. The publisher has no way to know if any subscriber received or processed the message.
Best for: live WebSocket broadcasts (broadcast to all connected workers to push to their sockets), cache invalidation signals, low-stakes notifications where the occasional dropped message is acceptable.
BullMQ — Durable Job Queues on Redis
BullMQ is the standard Node.js job queue library. It uses Redis for persistence, supports concurrent workers, retries with configurable backoff, delayed jobs, repeating (cron) jobs, prioritisation, and job progress reporting.
Job Lifecycle
Job Progress Reporting
Rate Limiting Workers
Inspecting the Queue
Flow Producers — Job Dependencies
A Flow lets you define parent/child job relationships. A parent job only moves to active after all its children complete. Useful for pipelines where you need all sub-tasks to finish before the next step runs.
Graceful Shutdown with BullMQ
💡 BullMQ vs Bull (v3)
BullMQ is the rewrite of the original Bull library. Key differences: BullMQ requires ioredis 5+ and uses a dedicated Worker class (Bull used the Queue for both producing and consuming). BullMQ is the actively maintained library — new projects should use BullMQ; Bull receives security fixes only.
For a web dashboard to inspect queues visually: Bull Board (@bull-board/express) works with both. Mount it at /admin/queues and protect it behind auth middleware.
| Feature | Redis Pub/Sub | Redis Streams | BullMQ |
|---|---|---|---|
| Persistence | No — missed if offline | Yes — replay from any position | Yes — stored in Redis until ACK'd |
| Delivery | Fan-out (all subscribers) | Fan-out or competing consumers | Competing consumers (one worker per job) |
| Retries | None | Manual via PEL | Built-in with backoff, max attempts |
| Delayed/Scheduled | No | No | Yes — delay, cron, repeat |
| Complexity | Low | Medium | Medium — but rich built-in features |
| Best for | Live events, cache invalidation | Event sourcing, audit logs | Background jobs, email, image processing |
Coding Challenges
Challenge 1 — In-Memory Job Queue
Build a SimpleQueue class (no Redis, no external packages) that: enqueues jobs with add(name, data, { priority, delayMs }), dispatches them to a registered processor function with process(name, concurrency, fn), retries failed jobs up to a configurable maxAttempts with a fixed delay between attempts, and emits 'completed' and 'failed' events. Demonstrate it with a 10-job batch where 3 jobs intentionally fail twice before succeeding on the third attempt, and verify the event counts are correct.
Challenge 2 — BullMQ Email Worker
Build a complete BullMQ producer/worker pair for an email queue. The producer exposes enqueueWelcome(to, name), enqueueReminder(to, delayMs), and enqueueDigest() (repeating, daily at 09:00). The worker handles all three job types, reports progress at 50% and 100%, and on failure logs the job ID, attempt number, and error. Write a test that enqueues 5 welcome jobs with a mock sender, waits for all to complete using job.waitUntilFinished, and asserts the mock was called exactly 5 times with the right data. Use ioredis-mock for the Redis connection.
Challenge 3 — Queue Health Dashboard
Build an Express route GET /queue-health that returns a JSON snapshot of every named queue passed to it: job counts per state (waiting/active/completed/failed/delayed), the oldest failed job (id, data, failedReason, attemptsMade), and a simple health verdict ("healthy" if failed count is 0, "degraded" if 1–9, "critical" if 10+). Protect the route with a hard-coded API key check using crypto.timingSafeEqual. Write tests that mock the Queue instance and verify the health verdict logic for all three bands.