Background Jobs and Task Queues with BullMQ23 min read
Module P-12·23 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.
Module P-12 — Background Jobs and Task Queues with BullMQ
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.
Why Job Queues
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:
typescript
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)
BullMQ Architecture
BullMQ uses Redis as its store. Three components:
text
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).
bash
Defining Queues and Adding Jobs
typescript
Adding jobs from your service:
typescript
Job options per-add
typescript
Writing Workers
A worker subscribes to a queue and processes jobs:
typescript
Running workers
Workers can run in the same process as your API (simple) or a separate process (recommended for production — independent scaling and crash isolation):
typescript
json
yaml
Stalled Jobs: Lock Renewal and Detection
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:
typescript
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.
Retries and Exponential Backoff
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:
typescript
Override per-job for different retry strategies:
typescript
Dead letter queue pattern
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:
typescript
Idempotent Job Design
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:
typescript
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:
typescript
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.
Repeatable Jobs (Cron)
BullMQ supports repeatable jobs using cron syntax:
typescript
The worker for scheduled jobs is identical to any other worker. BullMQ handles the timing.
Multiple Queues by Priority
Separate queues for different workload types:
typescript
BullMQ Dashboard
Inspect queues in a web UI with Bull Board:
bash
typescript
Visit /admin/queues to see job counts, retry failed jobs, clear queues, and inspect job data.
Real-World Patterns
Pattern 1: Always-on image processing
typescript
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):
typescript
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:
typescript
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.
Pattern 3: Bulk operations off the request path
typescript
Summary
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.
// Option 1: Inline — user waits 300ms for Mailgunexportconst 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 unreliableexportconst 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 reliableexportconst 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});
Producer (your API) Queue (Redis) Worker (separate process or same process)
│ │ │
│── queue.add() ──────►│ │
│ │◄──── worker.process() ────│
│ │ │
│ │──── job data ────────────►│
│ │ │ (processes job)
│ │◄──── completed ───────────│
npminstall bullmq ioredis
// src/queues/email.queue.tsimport{ Queue }from'bullmq';import IORedis from'ioredis';import{ env }from'../config/env.js';// Job type definitions — TypeScript safety across producer and workerexportinterfaceSendOrderConfirmationJob{ orderId:number;}exportinterfaceSendPasswordResetJob{ userId:number; resetToken:string;}exporttypeEmailJobData=|{ type:'order-confirmation'; data: SendOrderConfirmationJob }|{ type:'password-reset'; data: SendPasswordResetJob };// Create the queue — connects to Redisexportconst emailQueue =newQueue<EmailJobData>('email',{ connection:newIORedis(env.REDIS_URL,{ maxRetriesPerRequest:null}), defaultJobOptions:{ attempts:3,// retry up to 3 times backoff:{ type:'exponential', delay:1000,// 1s, 2s, 4s}, removeOnComplete:{ count:1000},// keep last 1000 completed jobs removeOnFail:{ count:5000},// keep last 5000 failed jobs for debugging},});
// High priority — processed before normal jobsawait 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 existsawait 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});
// src/workers/email.worker.tsimport{ 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';const worker =newWorker<EmailJobData>('email',async(job: Job<EmailJobData>)=>{ logger.info({ jobId: job.id, type: job.data.type },'Processing email job');switch(job.data.type){case'order-confirmation':{const{ orderId }= job.data.data;const order =await ordersRepo.findById(orderId);if(!order)thrownewError(`Order ${orderId} not found`);const user =await usersRepo.findById(order.userId);if(!user)thrownewError(`User ${order.userId} not found`);await emailService.sendOrderConfirmation({ to: user.email, orderNumber: order.id.toString(), items: order.items, total: order.total,});break;}case'password-reset':{const{ userId, resetToken }= job.data.data;const user =await usersRepo.findById(userId);if(!user)thrownewError(`User ${userId} not found`);await emailService.sendPasswordReset(user.email, resetToken);break;}default:thrownewError(`Unknown job type: ${(job.data asany).type}`);} logger.info({ jobId: job.id },'Email job completed');},{ connection:newIORedis(env.REDIS_URL,{ maxRetriesPerRequest:null}), concurrency:5,// process up to 5 jobs simultaneously limiter:{ max:10,// max 10 jobs per... duration:1000,// ...1 second (rate limit)},},);// Event listeners for observabilityworker.on('completed',(job)=>{ logger.info({ jobId: job.id, type: job.data.type },'Job completed');});worker.on('failed',(job, err)=>{ logger.error({ jobId: job?.id, type: job?.data.type, err },'Job failed');});worker.on('error',(err)=>{ logger.error({ err },'Worker error');});exportdefault worker;
// src/workers/index.ts — separate entry pointimport'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 shutdownprocess.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 exitsawaitPromise.all([emailWorker.close(), imageWorker.close(), reportWorker.close()]); logger.info('Workers drained and closed'); process.exit(0);});
# docker-compose.yml — separate container for workersservices:api:command: node dist/index.js
worker:command: node dist/workers/index.js
# Scale workers independently# docker-compose up --scale worker=3
const worker =newWorker<EmailJobData>('email',async(job)=>{/* ... */},{ connection:newIORedis(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},);
exportconst emailQueue =newQueue('email',{ connection:newIORedis(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},},});
// Critical payment job — more attempts, longer waitsawait 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});// Non-critical notification — fewer attemptsawait notificationQueue.add('push', payload,{ attempts:2, backoff:{ type:'fixed', delay:3000},});
worker.on('failed',async(job, err)=>{if(job && job.attemptsMade >=(job.opts.attempts ??1)){// Final failure — move to dead letter queueawait deadLetterQueue.add('failed-job',{ originalQueue:'email', jobName: job.name, jobData: job.data, error: err.message, failedAt:newDate().toISOString(),}); logger.error({ jobId: job.id, jobData: job.data },'Job moved to dead letter queue');}});
// src/workers/indexer.worker.tsasyncfunctionindexTransaction(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;}}
asyncfunctionprocessPayment(job: Job<{ orderId:number; idempotencyKey:string}>){const{ orderId, idempotencyKey }= job.data;const existing =await paymentsRepo.findByIdempotencyKey(idempotencyKey);if(existing)return existing;// already processed by an earlier attemptconst 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;}
// src/jobs/scheduled.tsimport{ Queue }from'bullmq';import IORedis from'ioredis';import{ env }from'../config/env.js';const schedulerQueue =newQueue('scheduler',{ connection:newIORedis(env.REDIS_URL,{ maxRetriesPerRequest:null}),});// Add repeatable jobs once at startup — BullMQ deduplicates automaticallyawait schedulerQueue.add('daily-digest',{ type:'daily-digest'},{ repeat:{ pattern:'0 8 * * *',// every day at 8:00 AM tz:'Asia/Kolkata',}, jobId:'daily-digest',// stable ID prevents duplicate schedule entries},);await schedulerQueue.add('cleanup-expired-tokens',{ type:'cleanup-tokens'},{ repeat:{ pattern:'0 * * * *',// every hour}, jobId:'cleanup-expired-tokens',},);await schedulerQueue.add('weekly-report',{ type:'weekly-report'},{ repeat:{ pattern:'0 9 * * 1',// every Monday at 9:00 AM tz:'Asia/Kolkata',}, jobId:'weekly-report',},);logger.info('Scheduled jobs registered');
// src/queues/index.tsimport{ Queue }from'bullmq';import{ env }from'../config/env.js';const connection ={ url: env.REDIS_URL};// Separate queues — each can have independent workers and scalingexportconst emailQueue =newQueue('email',{ connection });exportconst imageQueue =newQueue('image-processing',{ connection });exportconst reportQueue =newQueue('reports',{ connection });exportconst notificationQueue =newQueue('notifications',{ connection });// High-throughput queue with rate limitingexportconst webhookQueue =newQueue('webhooks',{ connection, defaultJobOptions:{ attempts:3, backoff:{ type:'exponential', delay:1000}, limiter:{ max:100, duration:1000},// 100 webhooks/second max},});
// When a user uploads an avatarawait imageQueue.add('process-avatar',{ userId: user.id, s3Key: uploadedKey, sizes:[{ width:32, height:32},{ width:128, height:128}],});
// After payment, trigger fulfilment and email in sequenceimport{ QueueEvents }from'bullmq';import IORedis from'ioredis';import{ env }from'../config/env.js';const queueEvents =newQueueEvents('payments',{ connection:newIORedis(env.REDIS_URL,{ maxRetriesPerRequest:null})});const paymentJob =await paymentQueue.add('process', paymentData);const result =await paymentJob.waitUntilFinished(queueEvents);await fulfilmentQueue.add('ship',{ orderId: result.orderId });await emailQueue.add('send-receipt',{ orderId: result.orderId });
import{ FlowProducer }from'bullmq';import IORedis from'ioredis';import{ env }from'../config/env.js';const flowProducer =newFlowProducer({ connection:newIORedis(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 }},],});
// User requests export — return immediately, email when doneexportconst requestExport =asyncHandler(async(req, res)=>{const job =await reportQueue.add('export',{ userId: req.user!.id, format: req.body.format, filters: req.body.filters,}); res.json({ message:'Export started. You will receive an email when it\'s ready.', jobId: job.id });});