BullMQ
BullMQ is the Redis-backed queue library most Node teams use for email, webhooks, media processing, and background sync.
Recipe
Quick-reference recipe card - copy-paste ready.
import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";
const connection = new IORedis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
export const reportQueue = new Queue("reports", { connection });
new Worker(
"reports",
async (job) => {
await buildReport(job.data.reportId);
},
{ connection: connection.duplicate(), concurrency: 3 }
);When to reach for this:
- You already run Redis for cache or sessions
- Need delayed jobs, retries, priorities, and observability UI (Bull Board)
- Node workers on ECS/k8s separate from API
Working Example
// src/queues/connection.ts
import IORedis from "ioredis";
export function createBullConnection() {
return new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
enableReadyCheck: false,
});
}
// src/queues/reports.ts
import { Queue, Worker, QueueEvents } from "bullmq";
import { createBullConnection } from "./connection";
import { buildReport } from "../services/reports";
const connection = createBullConnection();
export const reportsQueue = new Queue("reports", { connection });
export function startReportWorker() {
const worker = new Worker(
"reports",
async (job) => {
await job.updateProgress(10);
const url = await buildReport(job.data.reportId);
await job.updateProgress(100);
return { url };
},
{
connection: createBullConnection(),
concurrency: 2,
lockDuration: 30_000,
}
);
worker.on("failed", (job, err) => {
console.error({ msg: "job_failed", jobId: job?.id, err: err.message });
});
worker.on("stalled", (jobId) => {
console.warn({ msg: "job_stalled", jobId });
});
return worker;
}
// src/api/reports-route.ts
import express from "express";
import { reportsQueue } from "../queues/reports";
const router = express.Router();
router.post("/", async (req, res) => {
const job = await reportsQueue.add(
"build",
{ reportId: req.body.reportId },
{ attempts: 3, backoff: { type: "exponential", delay: 1000 } }
);
res.status(202).json({ jobId: job.id });
});
export default router;What this demonstrates:
- Dedicated connection factory for BullMQ requirements
- Progress updates for polling UI
failedandstalledhandlers for ops visibility- Exponential backoff on transient build failures
Deep Dive
Stalled Jobs
- Worker crash mid-job leaves lock until
lockDurationexpires - BullMQ moves job back to wait - may run twice
- Idempotent workers are mandatory, not optional
Graceful Shutdown
const worker = startReportWorker();
process.on("SIGTERM", async () => {
await worker.close(); // waits for current job by default
process.exit(0);
});- Pair with k8s
terminationGracePeriodSeconds> longest job duration - Stop accepting new HTTP enqueue during drain if needed
Repeatable Jobs
await reportsQueue.add(
"nightly",
{},
{ repeat: { pattern: "0 2 * * *" }, jobId: "nightly-report" }
);jobIdprevents duplicate repeatable definitions on redeploy- See Scheduling & Cron
FlowProducer (Parent/Child)
import { FlowProducer } from "bullmq";
const flow = new FlowProducer({ connection });
await flow.add({
name: "invoice-pdf",
queueName: "pdf",
data: { invoiceId },
children: [{ name: "fetch-line-items", queueName: "data", data: { invoiceId } }],
});- Parent runs after children complete
- Useful for pipeline without chaining manual enqueue in each worker
Bull Board (Ops UI)
- Mount
@bull-board/expressbehind admin auth - Lets support inspect failed jobs and retry manually
Gotchas
- Same ioredis connection for Queue and blocking Worker - subtle bugs. Fix: duplicate connections.
maxRetriesPerRequestdefault on worker - breaks BRPOP. Fix:nullon Bull connections.- Huge job data in Redis - memory pressure. Fix: S3 pointer in payload.
- No stalled monitoring - silent duplicate processing. Fix: alert on
stalledevents. - Infinite retries - poison message loops forever. Fix: cap attempts + DLQ review process.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| AWS SQS | No Redis ops, AWS-native | Need complex job flows without extra orchestration |
| pg-boss | Postgres-only stack | Redis already central |
| Temporal | Long-running sagas, human tasks | Simple email queue |
| RabbitMQ | Enterprise AMQP existing | Team has no Erlang ops appetite |
FAQs
BullMQ vs Bull v3?
BullMQ is maintained successor with better TypeScript and performance. New projects use BullMQ.
How many queues?
Split by domain and SLO: email, webhooks, media. Avoid one giant queue.
Concurrency per worker?
Start 2-5 I/O jobs. CPU-bound work: concurrency 1 per core after profiling.
Redis Cluster support?
Supported with prefix and hash tags per BullMQ docs. Test failover.
NestJS BullMQ?
BullModule.registerQueue in API module; separate worker bootstrap imports processors.
Job deduplication?
Stable jobId on queue.add. Combine with idempotency keys in worker.
Rate limiting outbound API?
BullMQ rate limiter option per queue protects vendor quotas.
Testing workers?
Use Redis Testcontainer; assert job completes and side effect occurred once.
Observability?
OpenTelemetry bullmq instrumentation + queue depth metrics (waiting, active, delayed).
Priority inversion?
High priority floods low - use separate queues for tiered SLAs instead.
Related
- Queues Basics - mental model
- ioredis - Redis client
- Idempotency Keys - safe retries
- Scheduling & Cron - repeatable jobs
- Queues Best Practices - depth alerts
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.