Background Job Processing with BullMQ and Redis: A Production Playbook for 2026
When I first started building production systems back in 2020, I treated background jobs like an afterthought — a setTimeout here, a cron job there, and a prayer that nothing crashed at 2 AM. Six years and 168+ projects later, I can tell you with absolute certainty: if your application does anything beyond CRUD, it needs a proper job queue. Email sends, image processing, webhook deliveries, AI inference calls, PDF generation — all of these belong in a background job system, not in your request lifecycle.
In 2026, the Node.js ecosystem has matured significantly, and the de facto standard for background job processing is BullMQ — a Redis-backed, TypeScript-first queue library that powers everything from startups to enterprises. In this playbook, I'll walk you through how to design, build, and operate a BullMQ-based job system in production using NestJS, covering retries, rate limiting, scheduled jobs, and observability patterns I've battle-tested with international clients.
Why BullMQ Over Other Queue Systems?
Before we dive into code, let's address the elephant in the room. The Node.js ecosystem has several queue options:
- BullMQ — Redis-based, modern, actively maintained, TypeScript-native
- Bull (v3) — The predecessor, now in maintenance mode
- Agenda — MongoDB-based, simpler but less performant
- Bee-Queue — Lightweight but limited features
- Temporal/Cadence — Heavyweight, polyglot, overkill for most use cases
For 95% of Node.js applications, BullMQ hits the sweet spot. It's built on Redis (which you probably already have), offers first-class TypeScript support, and provides battle-tested features like delayed jobs, repeatable jobs, rate limiting, and worker concurrency out of the box. In my experience building automation pipelines for e-commerce and SaaS clients, BullMQ handles tens of thousands of jobs per minute without breaking a sweat.
Setting Up BullMQ with NestJS
Let's start with the foundation. I'm a big fan of NestJS for production Node.js applications because of its modular architecture, dependency injection, and excellent TypeScript support. Here's how I typically structure a BullMQ integration.
Step 1: Install Dependencies
npm install @nestjs/bullmq bullmq
npm install --save-dev @types/bull
You'll also need a Redis instance. I recommend Redis 7.x or later, ideally managed (AWS ElastiCache, Upstash, or Redis Cloud) for production workloads.
Step 2: Configure the Queue Module
Create a dedicated module for your queues. I prefer one module per queue domain rather than a single monolithic queues.module.ts:
// email.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { EmailProcessor } from './email.processor';
import { EmailService } from './email.service';
@Module({
imports: [
BullModule.registerQueue({
name: 'email',
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
}),
],
providers: [EmailProcessor, EmailService],
exports: [EmailService],
})
export class EmailModule {}
Notice the defaultJobOptions — this is where production-grade defaults live. I always configure retries with exponential backoff, and I cap job retention to prevent Redis from growing unbounded.
Step 3: Create a Processor
Workers (called "processors" in BullMQ lingo) are where your actual work happens. Here's a robust email processor:
// email.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Counter, Histogram } from 'prom-client';
interface EmailJobData {
to: string;
subject: string;
template: string;
context: Record<string, any>;
}
@Processor('email', {
concurrency: 5,
limiter: {
max: 10,
duration: 1000,
},
})
export class EmailProcessor extends WorkerHost {
private readonly logger = new Logger(EmailProcessor.name);
constructor(
private readonly mailer: MailerService,
@InjectMetric('jobs_processed_total') private jobsCounter: Counter<string>,
@InjectMetric('job_duration_seconds') private jobDuration: Histogram<string>,
) {
super();
}
async process(job: Job<EmailJobData>): Promise<void> {
const timer = this.jobDuration.startTimer({ queue: 'email' });
try {
this.logger.log(`Processing email job ${job.id} to ${job.data.to}`);
// Move job to delayed state if rate limited
if (job.attemptsMade > 0) {
this.logger.warn(`Retry attempt ${job.attemptsMade} for job ${job.id}`);
}
await this.mailer.sendMail({
to: job.data.to,
subject: job.data.subject,
template: job.data.template,
context: job.data.context,
});
this.jobsCounter.inc({ queue: 'email', status: 'success' });
} catch (error) {
this.jobsCounter.inc({ queue: 'email', status: 'failed' });
this.logger.error(`Email job ${job.id} failed: ${error.message}`, error.stack);
throw error; // Re-throw to trigger retry
} finally {
timer();
}
}
}
The limiter configuration is critical — it prevents your worker from overwhelming downstream services like SendGrid or AWS SES. The Prometheus metrics give you production-grade observability.
Production-Grade Job Patterns
Let me share the patterns I use most often in client projects.
Pattern 1: The Fan-Out Pattern
When you need to process a batch of items independently, don't process them in a single job. Instead, fan out into multiple jobs:
async processBatch(userIds: string[]) {
const jobs = userIds.map((userId) => ({
name: 'send-notification',
data: { userId, message: 'Weekly digest ready' },
}));
await this.notificationQueue.addBulk(jobs);
}
Why? Because if one item fails, you don't lose the whole batch. Each job has its own retry logic, and you can scale workers horizontally to process them in parallel.
Pattern 2: The Saga Pattern with Parent/Child Jobs
For complex workflows, use BullMQ's parent-child job relationships:
const parent = await this.workflowQueue.add('order-workflow', { orderId });
const children = await this.workflowQueue.addBulk([
{ name: 'charge-payment', data: { orderId }, parent: { id: parent.id } },
{ name: 'reserve-inventory', data: { orderId }, parent: { id: parent.id } },
{ name: 'send-confirmation', data: { orderId }, parent: { id: parent.id } },
]);
// In processor:
async process(job: Job) {
if (job.name === 'order-workflow') {
const children = await job.getChildrenValues();
if (Object.values(children).every(c => c.status === 'completed')) {
await this.finalizeOrder(job.data.orderId);
}
}
}
Pattern 3: Scheduled Jobs (Cron)
BullMQ supports repeatable jobs natively — no need for separate cron libraries:
// Schedule a daily cleanup job
await this.maintenanceQueue.add(
'cleanup-expired-sessions',
{},
{
repeat: {
pattern: '0 2 * * *', // Every day at 2 AM
tz: 'America/New_York',
},
jobId: 'daily-cleanup', // Prevents duplicates
},
);
The jobId is crucial — it ensures you don't accidentally create duplicate scheduled jobs on every application restart.
Pattern 4: Priority Queues
Not all jobs are equal. Use priorities to ensure critical work gets processed first:
await this.queue.add('send-receipt', { orderId }, { priority: 1 }); // High priority
await this.queue.add('send-newsletter', { userId }, { priority: 10 }); // Low priority
Lower numbers = higher priority. Be careful though — high-priority jobs can starve low-priority ones if the queue is consistently busy. Use separate queues for different priority tiers.
Observability: The Difference Between Toy and Production
A job queue without observability is a black box waiting to fail. Here's my production monitoring stack:
1. BullMQ Board
The official BullMQ Board gives you a real-time UI for inspecting queues:
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(emailQueue), new BullMQAdapter(notificationQueue)],
serverAdapter,
});
2. Prometheus Metrics
Export the metrics I showed earlier to Prometheus and visualize in Grafana. The key metrics are:
jobs_processed_total{queue, status}— Counterjob_duration_seconds{queue}— Histogramjobs_in_queue{queue, state}— Gauge (waiting, active, delayed, failed)
3. Structured Logging
Always log job lifecycle events with context:
async process(job: Job) {
this.logger.log({
msg: 'Job started',
jobId: job.id,
queue: job.queueName,
attempt: job.attemptsMade + 1,
data: job.data,
});
// ...
}
4. Dead Letter Queue Inspection
Failed jobs accumulate in Redis. Build a dashboard or alert for jobs with attemptsMade >= maxAttempts. I typically set up a cron that pages me if more than 1% of jobs are failing in a 5-minute window.
Scaling Strategies for 2026
When your application grows, here's how to scale BullMQ:
Horizontal Worker Scaling
BullMQ workers are stateless — just run more of them. Use Kubernetes HPA based on Redis queue depth:
metrics:
- type: Pods
metric:
name: bullmq_queue_depth
target:
type: AverageValue
averageValue: "100"
Redis Sharding
For very high throughput (>50K jobs/min), consider Redis Cluster. BullMQ supports it natively, but you'll need to configure prefix correctly to avoid key collisions.
Separate Queues for Different Concerns
Don't put everything in one mega-queue. Separate by:
- Domain (email, notifications, billing)
- Latency requirement (real-time vs batch)
- Resource profile (CPU-heavy vs IO-heavy)
This lets you scale workers independently and prevents one slow job type from blocking others.
Common Pitfalls I See in Production
After deploying BullMQ for dozens of clients, here are the mistakes I see repeatedly:
- Not setting
removeOnComplete/removeOnFail— Redis fills up with completed job data, eventually causing OOM. - Ignoring graceful shutdown — Always close workers gracefully on SIGTERM to finish in-progress jobs.
- Putting too much data in job payloads — Keep payloads small; store large data in S3 and pass URLs.
- Forgetting idempotency — Jobs may run multiple times due to retries. Design processors to be idempotent.
- No alerting on dead letter queues — Failed jobs that exhaust retries need human attention.
Graceful Shutdown Example
// main.ts
app.enableShutdownHooks();
// In your processor:
async onModuleDestroy() {
await this.worker.close(); // Waits for active jobs to finish
}
Wrapping Up
Background job processing is one of those things that seems simple until it isn't. By using BullMQ with Redis and NestJS, you get a production-grade foundation that scales from your first thousand users to your first million. The patterns I've shared — fan-out, sagas, scheduled jobs, and proper observability — are the same ones I use in client engagements ranging from e-commerce automation to AI-powered SaaS platforms.
If you're building a system that needs reliable background processing in 2026, invest the time to set up BullMQ correctly from day one. Your future self (and your on-call rotation) will thank you.
Need help architecting a job queue system for your application? I specialize in building scalable Node.js backends with NestJS, BullMQ, and Redis. Let's talk about your project — check out my Fiverr profile or reach out directly through my portfolio.
FAQ
What is the difference between BullMQ and Bull?
BullMQ is the modern, actively-maintained successor to Bull. It has better TypeScript support, improved performance, and supports features like parent-child jobs and sandboxed processors. Bull is now in maintenance mode and only receives critical security updates.
How many jobs can BullMQ handle?
On a modest Redis instance (4GB RAM, 2 vCPU), BullMQ reliably handles 10,000-50,000 jobs per minute depending on job complexity. With Redis Cluster and horizontal worker scaling, throughput can exceed 1 million jobs per minute.
Should I use BullMQ or Temporal for my Node.js application?
For most Node.js use cases, BullMQ is the right choice — it's simpler, Redis-based, and integrates seamlessly with the Node ecosystem. Temporal is better for complex multi-service workflows requiring strong consistency guarantees, but adds significant operational complexity.
How do I handle failed jobs in BullMQ?
Configure attempts and backoff in job options. Failed jobs that exhaust retries remain in the "failed" state for inspection. Use Bull Board UI or query queue.getFailed() programmatically to handle them. For critical failures, integrate with Sentry or PagerDuty.
Is BullMQ production-ready?
Yes. BullMQ powers companies like Shopify, GitLab, and thousands of production systems. It has 99.9%+ uptime when paired with managed Redis, and the library is actively maintained by Taskforce.sh.
How-To: Set Up BullMQ in a NestJS Project
-
Install dependencies and configure Redis — Run
npm install @nestjs/bullmq bullmqand provision a Redis 7.x instance. Update your environment variables with the Redis connection URL. -
Register the queue module — Create a dedicated module file (e.g.,
email.module.ts) and useBullModule.registerQueue()with production-safe defaults including retry attempts, exponential backoff, and job retention limits. -
Build the processor with metrics — Extend
WorkerHost, setconcurrencyandlimiteroptions, and integrate Prometheus counters and histograms to track job success rates and processing duration. -
Add scheduled jobs and observability — Use the
repeatoption with cron patterns for scheduled tasks, mount Bull Board UI for visual debugging, and set up alerts on queue depth and failure rates. -
Configure graceful shutdown and deploy — Implement
onModuleDestroy()to close workers cleanly, deploy with Kubernetes HPA scaling based on Redis queue depth, and verify idempotency for all job types.
