Warming up the neural circuits...
By the end of this chapter you will:
An HTTP request is a dinner reservation — you have 30 seconds before the table is given away. Background jobs are the kitchen prep that happens hours before the doors open.
You're at a restaurant. You order a steak. The waiter (HTTP request) takes your order and walks to the kitchen. Now imagine the chef doesn't have pre-chopped onions, pre-heated grill, or pre-made stock. The chef starts chopping onions NOW. Your steak takes 45 minutes. You leave a 1-star review.
Smart restaurants do prep work before service — and during service, they parallelize. While the steak grills (5 min), the salad station plates your appetizer (2 min), and the pastry chef finishes your dessert (no extra wait). Background jobs are your kitchen prep. When a user hits "Purchase," your says "Got it!" in 200ms, and a background worker generates the invoice PDF, sends the confirmation email, updates analytics, and warms the — all without the user waiting.
This chapter covers BullMQ (the dominant Redis-backed job queue for Node.js), the full job lifecycle, retry strategies that actually work in production, idempotency so you don't double-charge customers, and dead letter queues so failed jobs don't vanish into the void. You'll learn patterns that Stripe, GitHub, and every SaaS company use to keep their API fast while doing heavy lifting behind the scenes.
setTimeout or Promise.all?Before job queues, developers reach for in-process solutions:
// Anti-pattern 1: Inline async (blocks response)
app.post('/purchase', async (req, res) => {
const order = await createOrder(req.body);
await generateInvoicePDF(order); //
Fire-and-forget has three fatal flaws: (1) if the process crashes, work is lost forever, (2) if the email API is down, there's no retry, (3) there's no visibility — you don't know if the PDF was generated or why it failed.
BullMQ uses Redis as its backbone. The architecture has four components:
Producer (API server) Redis Worker (separate process)
┌──────────┐ ┌─────────────────┐ ┌──────────┐
│ Add job │──RPUSH──►│ waiting (list) │──LPOP──►│ Process │
│ to queue │ │ active (list) │ │ job │
└──────────┘ │ completed (set) │ └──────────┘
│ failed (set) │
│ delayed (zset) │
└─────────────────┘Producer: Your API server adds jobs to the queue. It resolves immediately after Redis confirms receipt — single-digit milliseconds.
Redis: Stores jobs in lists, sets, and sorted sets. Jobs in waiting are FIFO. Jobs in delayed use a sorted set scored by execution time. Jobs in completed/failed are sets with optional TTL.
Worker: A separate process that pulls jobs from Redis, processes them, and reports results back. Workers can run on different machines — they compete for jobs via Redis atomic operations.
import { Queue, Worker, Job } from 'bullmq';
import { createClient } from 'redis';
const connection = createClient({ url: process.env.REDIS_URL
import { purchaseQueue } from '../queues/purchase-queue';
app.post('/api/purchase', async (req, res) => {
const order = await createOrder(req.body);
Every job in BullMQ transitions through states:
┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────────┐
│ waiting │────►│ active │────►│completed │ or │ failed │
└─────────┘ └─────────┘ └──────────┘ └─────┬─────┘
▲ │
│ ┌─────────┐ │
└──────────────│ delayed │◄────── retry ────────────┘
└─────────┘ (if attempts remain)delay option)import { Worker, Job } from 'bullmq';
import { purchaseQueue } from '../queues/purchase-queue';
import { generateInvoicePDF } from '../services/invoice';
import { sendConfirmationEmail } from '../services/email'
The concurrency: 10 option means this worker process runs up to 10 jobs simultaneously. Since most job work is I/O-bound (database, email API, S3), high concurrency is appropriate. For CPU-bound jobs (image processing, PDF generation), set concurrency to the number of CPU cores.
Not all failures are equal. A network timeout to the email API should retry. A "user not found" error should NOT retry — it will never succeed.
import { Worker, UnrecoverableError } from 'bullmq';
const worker = new Worker('purchase', async (job: Job) => {
try {
return await processPurchaseJob(job
Idempotency means processing the same job twice produces the same result as processing it once. Without it, you WILL double-charge customers. Here's why it matters:
A worker pulls a job, processes it, but the Redis connection drops before it can report "completed." Redis still thinks the job is active. After a timeout, another worker picks up the "same" job and processes it again. Now the customer gets two confirmation emails, two invoice PDFs, and potentially two charges.
Solution: Job IDs as idempotency keys.
// The jobId IS the idempotency key
await purchaseQueue.add('send-purchase-emails', data, {
jobId: `purchase-${order.id}`, // Same order = same jobId
});
// If this code runs twice (retry, duplicate webhook),
//Inside the worker — check before acting:
async function processPurchaseJob(job: Job) {
const { orderId } = job.data;
// Idempotency check: was this already done?
const existingInvoice = await db.query(
'SELECT id FROM invoices WHERE order_id = $1',
[orderId
After 5 retries, a job lands in failed. But some failures need human intervention: a payment that partially succeeded, an email to a malformed address, a PDF for a deleted order. You don't want these mixed in with transient network failures.
import { Queue, Worker, QueueEvents } from 'bullmq';
const mainQueue = new Queue('purchase', { connection });
const deadLetterQueue = new Queue('
A separate dashboard or admin endpoint lets support staff inspect the DLQ and manually retry or discard jobs.
BullMQ supports repeatable jobs — jobs that run on a schedule, powered by Redis sorted sets under the hood:
// Daily cleanup: delete expired tokens
await cleanupQueue.add(
'cleanup-expired-tokens',
{},
{
repeat: {
pattern: '0 3 * * *', // Every day at 3 AM
},
jobId: 'cleanup-expired-tokens', // Unique — prevents duplicate schedules
}
| Backend | Best for | Watch out for |
|---|---|---|
| BullMQ (Redis) | Node.js apps, < 10K jobs/sec, simple setup | Redis is in-memory — jobs are lost if Redis crashes without persistence |
| RabbitMQ | Polyglot (multiple languages), complex routing, > 10K jobs/sec | Operational complexity; need Erlang knowledge to debug |
| Amazon SQS | Zero-ops, AWS ecosystem, infinite scale | At-least-once delivery (idempotency is mandatory); message size limit 256KB |
| Google Cloud Tasks | GCP ecosystem, exact-once delivery (almost) | Vendor lock-in; lower throughput than SQS |
| Apache Kafka | Event streaming, replayability, > 100K messages/sec | Heavy ops; overkill for simple job queues |
Stripe's payment processing pipeline is a masterclass in job queue design. When you call POST /v1/charges, Stripe's API returns in under 200ms with a charge object that has status: "pending". The actual work happens in a series of asynchronous jobs:
The pipeline:
charge record (status: pending).payment-processing queue. The API returns immediately.succeeded, and enqueues a delivery job.Why Stripe's approach matters for your architecture:
/v1/charges endpoint doesn't wait for fraud analysis (200ms ML inference) or card network round-trips (500ms-2s). It enqueues and returns.Idempotency-Key header. Stripe stores the response of the first request and replays it for duplicates — safe to retry network failures infinitely.| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| No jobId / idempotency key | Duplicate webhook → duplicate job → double charge | Use jobId: \purchase-$`` — BullMQ deduplicates by job name + ID |
| Fire-and-forget (no queue) | Server crash → lost work; no retry, no visibility | Always use a queue for work outside the request cycle |
| Infinite retries | A poisoned job (bad data) retries forever, consuming worker capacity | Set attempts: 5 max; use UnrecoverableError for permanent failures |
| No dead letter queue | Failed jobs pile up in the main queue's failed set; hard to find the ones needing human review | Route exhausted jobs to a DLQ; build a simple admin UI for inspection and retry |
| Long-running jobs without timeout | A stuck job holds a worker slot forever; queue backs up | Set timeout: 30_000 (30s) per job type; workers kill jobs that exceed it |
| Job data too large | Storing whole request bodies (including uploaded files) in Redis bloats memory | Store references (URLs, S3 keys), not binary data; keep job data under 10KB |
| Processing in the API process | Worker competes with HTTP for CPU and memory; a bad job can crash your API | Run workers in a separate process (different dyno/pod/container) |
appendonly yes in Redis config, a Redis restart wipes all jobs. Use AOF persistence. For the truly paranoid, snapshot (RDB) + AOF together.bull-board package gives you a web UI to see queue lengths, job statuses, retry counts, and job data. Install it at /admin/queues behind authentication.worker.pause()), wait for active jobs to complete (worker.close() with timeout), then exit. Incomplete jobs return to waiting for another worker to pick up.allkeys-lru) kicks in, it can evict active jobs. Queue Redis needs noeviction policy.waiting count exceeds a threshold for > 5 minutes, something is wrong — workers are down or can't keep up. Set PagerDuty alerts on queue metrics.concurrency: 10 per worker process. Increase until Redis CPU hits ~70% or worker memory hits your limit. More concurrency ≠ more throughput if your bottleneck is an external API with rate limits.SandboxedProcessor — it runs the job in a child process, preventing it from blocking the of the main worker.queue.addBulk() to add many jobs in a single pipeline call: await queue.addBulk(jobs.map(data => ({ name: 'task', data }))).rate-limit per worker or a token bucket in Redis to throttle.waiting for > 10 minutes, something is wrong (zero workers, Redis disconnected). Monitor job.timestamp vs current time and alert on stale jobs.removeOnComplete with a reasonable TTL. Don't retain PII in job data longer than necessary for debugging.Move email sending to a queue. Take an Express endpoint that sends a welcome email inline. Create a BullMQ queue and worker. The endpoint adds a job; the worker sends the email. Verify that the API responds in < 50ms while the email sends asynchronously.
Add retry logic. Configure the queue with attempts: 3 and exponential backoff. Simulate a failing email API (throw an error 2 times, succeed on the 3rd). Confirm the job retries and eventually completes.
Build a dead letter queue. After 3 failed attempts, move the job to a separate purchase-dead-letter queue. Write an admin endpoint that lists DLQ jobs and allows retrying them back to the main queue.
Implement idempotency. Create an order processing job that uses orderId as the jobId. Write a test that adds the same job twice — the second add() should return the existing job, not create a duplicate. Inside the worker, add a database check that skips processing if the invoice already exists.
Rate-limited external API worker. Your worker calls a third-party API limited to 50 requests per second. Implement a token bucket rate limiter in Redis that spans all worker instances. Workers acquire a token before making the API call and release it after. Without it, simultaneous workers exceed the rate limit.
Build a cron-based report generator. Create a repeatable job that runs every Monday at 9 AM. It queries the database for last week's orders, generates a CSV report, uploads it to S3, and emails a link to the admin. Handle edge cases: what if the report generation fails? What if it takes longer than a week (overlapping runs)? Use a lock (Redis SET NX) to prevent concurrent executions.
Q1: Why move work to a background queue instead of doing it in the HTTP request handler?
Answer: Three reasons: (1) User experience — the API responds in milliseconds instead of seconds. (2) Reliability — if the work fails, the queue retries with backoff; a failed inline handler just returns a 500. (3) Resource isolation — slow work in the API process blocks the event loop for all users. Background workers are separate processes that can be scaled independently.
Q2: What's a job ID and why does it matter?
Answer: A job ID is a unique identifier assigned when a job is added to the queue. In BullMQ, adding a job with the same jobId returns the existing job instead of creating a duplicate. This is the foundation of idempotency — it prevents duplicate work from duplicate webhook calls, retry storms, or buggy producers.
Q3: What happens to a job that fails all its retry attempts?
Answer: In BullMQ, it stays in the failed set. It's not automatically removed or retried. Without a dead letter queue, failed jobs accumulate in the main queue's failed set, mixed with transient failures. The best practice is to listen for failed events and move exhausted jobs to a dedicated dead letter queue for inspection and manual retry.
Q4: How do you handle a job that takes 10 minutes but your Redis connection drops after 5?
Answer: BullMQ uses Redis locks with a TTL ("stalled check"). When a worker claims a job, it sets a lock with a TTL (default 30s). The worker periodically renews the lock while the job is running. If the lock expires (worker crashed, Redis disconnected), another worker detects the "stalled" job and picks it up. This is why idempotency is critical — the job might be processed twice. The stalled check interval and lock duration are configurable via stalledInterval and lockDuration.
Q5: You're migrating from in-process to BullMQ. How do you ensure zero data loss during the migration?
Answer: I'd implement a dual-write pattern: (1) Add the queue alongside the existing inline processing (both run). (2) The worker checks a database flag: if the work was already done in-process, skip. (3) Monitor for a week — confirm queue-based results match inline results. (4) Flip a feature flag to route 100% of traffic through the queue. (5) Remove the inline code. The key is that the worker is from day one — it always checks if the work was already completed.
Q6: Compare BullMQ, RabbitMQ, and SQS for a payment processing pipeline. Which would you choose and why?
Answer: For payment processing where correctness > everything: I'd lean toward SQS + SNS because of zero-ops reliability (AWS manages it, 99.9%+ uptime) and dead letter queue support built in. But SQS doesn't support job progress or delayed jobs natively (you use visibility timeout hacks). For a team already running Redis, BullMQ wins on developer experience — job progress, Bull Board UI, repeatable jobs, and it's all in Node.js (same language as the API). RabbitMQ wins for polyglot teams (Java, Go, Python workers) and complex routing patterns (topic exchanges, header-based routing). My default for a Node.js shop: BullMQ for simplicity, SQS if "we never want to think about Redis again."