Background Jobs and Task Queues with BullMQ18 min read
Module P-12·18 min read
Why job queues exist, BullMQ with Redis, workers and concurrency, retries and exponential backoff, dead letter queues, cron jobs, and real-world use cases.
What this module covers: Some work is too slow, too unreliable, or too risky to do inside a request. Sending email, resizing images, calling third-party APIs, generating reports, sending push notifications — all of these should happen off the request path. This module covers why job queues exist, BullMQ's architecture with Redis, defining and dispatching jobs, writing workers with concurrency control, retries with exponential backoff, dead letter queues for failed jobs, repeatable cron jobs, and the patterns that make queues reliable in production.
A job queue is a restaurant's order rail — the waiter can hand off a ticket the instant it's written and get back to the floor, while the kitchen fires it at its own pace.
Consider sending a transactional email when an order is placed. Three approaches:
// Option 1: Inline — user waits 300ms for Mailgun export const createOrder = asyncHandler(async (req, res) => { const order = await ordersService.createOrder(req.body); await emailService.sendConfirmation(order); // blocks the response res.status(201).json(order); });
// Option 2: Fire-and-forget — fast but unreliable export const createOrder = asyncHandler(async (req, res) => { const order = await ordersService.createOrder(req.body); emailService.sendConfirmation(order).catch(console.error); // if this fails, no retry res.status(201).json(order); });
// Option 3: Job queue — fast AND reliable export const createOrder = asyncHandler(async (req, res) => { const order = await ordersService.createOrder(req.body); await emailQueue.add('send-confirmation', { orderId: order.id }); // returns in <1ms res.status(201).json(order); // response goes out immediately // Worker picks up the job, retries on failure, logs results });
A job queue gives you:
Decoupled execution — the response returns before the work is done
Automatic retries — if the email provider is down, the job retries with backoff
Visibility — you can see queued, active, completed, and failed jobs
Rate limiting — process at most N jobs per second regardless of how many are enqueued
Concurrency control — run M workers in parallel without overloading the system
Persistence — jobs survive process restarts (stored in Redis)
Producer (your API) Queue (Redis) Worker (separate process or same process) │ │ │ │── queue.add() ──────►│ │ │ │◄──── worker.process() ────│ │ │ │ │ │──── job data ────────────►│ │ │ │ (processes job) │ │◄──── completed ───────────│
The queue lives in Redis — producers and workers are just Node.js processes that connect to it. You can have multiple producers (different API instances) and multiple workers (separate scaling).
// High priority — processed before normal jobs await notificationQueue.add('push', payload, { priority: 1 });
// Delay — run 10 minutes from now (e.g. "your session expires soon") await emailQueue.add('expiry-warning', payload, { delay: 10 * 60 * 1000 });
// Unique job — skip if a job with this key already exists await reportQueue.add('daily-report', payload, { jobId: `daily-report:${today}`, // deterministic ID prevents duplicates });
// LIFO — process newest first (useful for real-time notifications) await notificationQueue.add('push', payload, { lifo: true });
A worker subscribes to a queue and processes jobs:
// src/workers/email.worker.ts import { Worker, Job } from 'bullmq'; import IORedis from 'ioredis'; import { EmailJobData } from '../queues/email.queue.js'; import * as emailService from '../services/email.service.js'; import * as ordersRepo from '../repositories/orders.repository.js'; import * as usersRepo from '../repositories/users.repository.js'; import logger from '../utils/logger.js'; import { env } from '../config/env.js';
switch (job.data.type) { case 'order-confirmation': { const { orderId } = job.data.data; const order = await ordersRepo.findById(orderId); if (!order) throw new Error(`Order ${orderId} not found`);
const user = await usersRepo.findById(order.userId); if (!user) throw new Error(`User ${order.userId} not found`);
case 'password-reset': { const { userId, resetToken } = job.data.data; const user = await usersRepo.findById(userId); if (!user) throw new Error(`User ${userId} not found`);
Workers can run in the same process as your API (simple) or a separate process (recommended for production — independent scaling and crash isolation):
// src/workers/index.ts — separate entry point import 'dotenv/config'; import emailWorker from './email.worker.js'; import imageWorker from './image.worker.js'; import reportWorker from './report.worker.js'; import logger from '../utils/logger.js';
logger.info('Workers started');
// Graceful shutdown process.on('SIGTERM', async () => { logger.info('Shutting down workers...'); // worker.close() waits for in-progress jobs to finish before resolving — // this is what actually drains current jobs before the process exits await Promise.all([emailWorker.close(), imageWorker.close(), reportWorker.close()]); logger.info('Workers drained and closed'); process.exit(0); });
When a worker picks up a job, it holds a Redis-backed lock on it for lockDuration (default 30s), renewing that lock automatically while the job keeps running. If the worker process dies, freezes on a blocking call, or gets killed mid-job by a deploy, the lock stops being renewed. Once it expires, BullMQ's stalledInterval check (default: every 30s) notices the job is locked but hasn't been renewed, and puts it back on the queue for another worker to pick up:
const worker = new Worker<EmailJobData>( 'email', async (job) => { /* ... */ }, { connection: new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null }), concurrency: 5, lockDuration: 30_000, // how long a worker holds a job before it can be considered stalled stalledInterval: 30_000, // how often BullMQ scans for stalled jobs maxStalledCount: 1, // how many times a job may stall before it's marked failed outright }, );
Two consequences worth knowing before they surprise you in production:
A job that legitimately runs longer than lockDuration gets marked stalled and reassigned to another worker even though the original worker is still processing it fine. This is the classic BullMQ footgun for slow jobs — if a job routinely takes longer than the default 30s, raise lockDuration to comfortably exceed its expected runtime, not just nudge it up slightly.
maxStalledCount caps how many times a job can stall before BullMQ gives up on it — a job that stalls repeatedly (e.g. the worker keeps crashing on it specifically) is moved to failed after this many stalls rather than bouncing between workers forever.
Because a recovered job can end up processed by two workers in an overlapping window, stalled-job recovery is another reason job processors need to be idempotent — not just retries after an outright failure.
BullMQ, like virtually every job queue, guarantees at-least-once delivery — not exactly-once. A block-indexing worker once crashed mid-batch: it had written half its rows to Postgres but hadn't yet reached the point where the job gets acknowledged as complete. BullMQ, correctly, retried it. The processor wasn't idempotent, so the retry re-ran the whole batch — thousands of already-indexed transactions got inserted a second time, and every downstream balance calculation that summed those rows quietly went wrong. Nothing threw an error; the bug surfaced only when a reconciliation audit found balances that didn't match on-chain totals, until a unique constraint on (tx_hash, log_index) was added and started rejecting the duplicate inserts, which is what finally pointed at the retry as the cause.
When a job fails (throws an error), BullMQ retries it according to the attempts and backoff settings:
export const emailQueue = new Queue('email', { connection: new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null }), defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 2000, // initial delay in ms // attempts: 5 means 5 total attempts = 4 retries after the first try // Retry schedule: 2s, 4s, 8s, 16s }, }, });
Override per-job for different retry strategies:
// Critical payment job — more attempts, longer waits await paymentQueue.add('process-payment', payload, { attempts: 10, backoff: { type: 'exponential', delay: 5000 }, // attempts: 10 means 10 total attempts = 9 retries after the first try // Retries: 5s, 10s, 20s, 40s, 80s, 160s, 320s, 640s, 1280s });
After all retry attempts are exhausted, jobs land in BullMQ's failed set. For critical jobs, move them to a dedicated dead letter queue for manual review:
worker.on('failed', async (job, err) => { if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) { // Final failure — move to dead letter queue await deadLetterQueue.add('failed-job', { originalQueue: 'email', jobName: job.name, jobData: job.data, error: err.message, failedAt: new Date().toISOString(), });
logger.error({ jobId: job.id, jobData: job.data }, 'Job moved to dead letter queue'); } });
Retries mean "at least once," not "exactly once" — any job can run twice (a crash after the DB write but before the ack, a network blip during the completion handshake, a stalled-job recovery reassigning a job that's still finishing elsewhere). If a job's side effect isn't safe to repeat, a retry turns into a bug instead of a fix. Two patterns make a processor safe to re-run:
Pattern A: a unique constraint as the dedup gate. Let the database reject the duplicate instead of trying to detect it yourself:
// src/workers/indexer.worker.ts async function indexTransaction(tx: OnchainTx) { try { await db.query( `INSERT INTO indexed_transactions (tx_hash, log_index, from_addr, to_addr, amount) VALUES ($1, $2, $3, $4, $5)`, [tx.hash, tx.logIndex, tx.from, tx.to, tx.amount], ); } catch (err) { if (isUniqueViolation(err)) { // Already indexed by a previous attempt — this is the expected retry path, not an error logger.info({ txHash: tx.hash, logIndex: tx.logIndex }, 'Skipping duplicate — already indexed'); return; } throw err; } }
The UNIQUE (tx_hash, log_index) constraint does the idempotency work — the second insert fails fast and cheaply, and the worker treats that specific failure as success instead of retrying again.
Pattern B: check-then-write with an idempotency key. Useful when the operation isn't a plain insert — e.g. calling a third-party payment API:
const existing = await paymentsRepo.findByIdempotencyKey(idempotencyKey); if (existing) return existing; // already processed by an earlier attempt
const result = await stripeClient.charges.create( { amount, currency: 'usd', customer }, { idempotencyKey }, // Stripe dedupes on this key server-side too — belt and suspenders ); await paymentsRepo.create({ orderId, idempotencyKey, stripeChargeId: result.id }); return result; }
Derive the idempotency key from job.id (or a value stored in job.data) so that every retry of the same job reuses the same key. Most payment APIs accept an idempotency key argument for exactly this reason — use it instead of inventing your own dedup logic against their API.
// When a user uploads an avatar await imageQueue.add('process-avatar', { userId: user.id, s3Key: uploadedKey, sizes: [{ width: 32, height: 32 }, { width: 128, height: 128 }], });
Pattern 2: Chaining jobs
Job instances aren't event emitters — there's no per-job .on('completed', ...). Listening for one specific job's completion means using a QueueEvents instance (or job.waitUntilFinished(), which uses QueueEvents under the hood):
// After payment, trigger fulfilment and email in sequence import { QueueEvents } from 'bullmq'; import IORedis from 'ioredis'; import { env } from '../config/env.js';
const queueEvents = new QueueEvents('payments', { connection: new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null }) }); const paymentJob = await paymentQueue.add('process', paymentData);
This works for a simple two-step sequence, but it's a hand-rolled version of something BullMQ already ships. FlowProducer models job dependencies natively — the direction runs opposite to the waitUntilFinished chain above: children execute first, and the parent only starts once every child has completed, with each child's return value available to the parent via job.getChildrenValues(). That maps directly onto dependencies your own code can't express cleanly with sequential chaining — for example, an order can only ship once payment has cleared and an inventory hold has been confirmed, two independent precursor jobs rather than one:
import { FlowProducer } from 'bullmq'; import IORedis from 'ioredis'; import { env } from '../config/env.js';
const flowProducer = new FlowProducer({ connection: new IORedis(env.REDIS_URL, { maxRetriesPerRequest: null }) });
// `ship` is the parent — its processor won't be picked up until both children below // have completed, and it can read each child's result via job.getChildrenValues() await flowProducer.add({ name: 'ship', queueName: 'fulfilment', data: { orderId: paymentData.orderId }, children: [ { name: 'process-payment', queueName: 'payments', data: paymentData }, { name: 'check-inventory', queueName: 'inventory', data: { orderId: paymentData.orderId } }, ], });
Children can have their own children in turn, so FlowProducer also covers multi-level trees. For a plain "run A, then run B" sequence, waitUntilFinished chaining is still the simplest option; FlowProducer earns its keep once a step depends on more than one precursor completing, or the dependency graph goes deeper than one level — situations where nested waitUntilFinished calls get unwieldy fast.
Job queues decouple slow/unreliable work from the request path. The response returns in milliseconds; the worker does the heavy lifting asynchronously.
BullMQ uses Redis for persistence — jobs survive process restarts. Workers and producers are just Node.js processes connected to the same Redis instance.
Exponential backoff retries transient failures automatically. Failed jobs after all attempts go to the failed set — move them to a dead letter queue for critical workflows.
Concurrency and rate limiting prevent workers from overwhelming downstream services. Set concurrency per worker instance, limiter per queue.
Repeatable jobs replace setInterval for cron-style work — they persist across restarts and won't double-schedule.
Separate queues by workload type so a spike in image processing doesn't delay emails.
Bull Board provides a web UI for inspecting, retrying, and clearing jobs without writing code.
Next: JSON internals — what JSON.parse and JSON.stringify actually do, the edge cases that bite at scale, streaming JSON for large payloads, and when MessagePack is worth the complexity.
Knowledge Check
What is a key architectural benefit of using a job queue like BullMQ instead of using an inline await or fire-and-forget promise.catch() inside a request handler?
In BullMQ, what does setting attempts: 5 and a backoff: { type: 'exponential', delay: 2000 } achieve when a job fails?
Why is it generally recommended to run BullMQ workers in a separate process or container from the main Express API in a production environment?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.