bullmq
BullMQ is a Redis-backed job queue for Node.js: you add jobs to named queues and worker processes pick them up, with retries, delays, priorities, rate limiting, cron-style schedulers, and parent-child job flows. Operations run as atomic Lua scripts, which is why it survives crashes and concurrent workers without double-processing. It is the maintained successor to Bull, run by Taskforce.sh, with official ports for Python, Rust, Elixir, and PHP, and the v6 release (July 30, 2026) introduced pluggable backends beyond a single Redis client.
The default job queue for Node when Redis is on the table: atomic, fast, and actively developed with commercial backing. Accept the cost of running Redis and the Pro paywall on some features, and pick v5 or v6 deliberately since v6 is brand new.
Use it if
- You already run Redis (or Valkey/Dragonfly) and need background jobs with retries, backoff, and scheduled or repeating work
- You need orchestration: parent jobs that wait on children (flows), queue-level rate limits, per-worker concurrency
- You use NestJS, where the official @nestjs/bullmq integration makes this the default queue
- You are on the old Bull package and want the maintained successor with the same mental model
- You do not want to operate Redis: pg-boss gives you a competent queue inside the Postgres you already run
- Your jobs are simple periodic tasks in one process: a cron library is far less machinery than queue plus worker plus Redis
- You need groups, batches, or observables: those live in the paid BullMQ Pro package, not the open-source one
- You deploy serverless: workers are long-lived processes holding blocking Redis connections, which does not fit Lambda-style runtimes without extra plumbing
- You want boring and settled: v6 shipped days ago and changed the connection model, so most tutorials and AI answers still describe v5
Setup reality
npm install bullmq, plus a Redis 6.2+ instance you run yourself, so docker compose becomes part of local dev. Since v6 the Redis client is an optional peer dependency: you install ioredis, redis v5+, or @valkey/valkey-glide and hand BullMQ the connection. The two classic footguns: ioredis connections for workers need maxRetriesPerRequest: null, and completed or failed jobs stay in Redis forever unless you set removeOnComplete and removeOnFail. Types are good, but the config surface (job options, worker options, events) takes a day to internalize.
Patterns
Add a job and process itadd-and-process
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({ maxRetriesPerRequest: null });
const queue = new Queue('emails', { connection });
await queue.add('welcome', { to: 'user@example.com' });
const worker = new Worker('emails', async (job) => {
await sendEmail(job.data.to);
}, { connection });Since v6 you install the Redis client yourself (ioredis here); worker connections need maxRetriesPerRequest: null or BullMQ rejects them.
Retry failed jobs with backoffretry-backoff
await queue.add('charge', { orderId: 42 }, {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 }
});Jobs only retry when attempts > 1; after the last attempt the job lands in the failed set and sits there until you act or a cleanup policy removes it.
Schedule a job for laterdelayed-job
await queue.add('reminder', { userId: 7 }, { delay: 60_000 });Delay counts from add time. If no worker is running when the delay expires, the job just waits for the next worker to come up.
Run a job on a cron schedulerepeat-cron
await queue.upsertJobScheduler(
'nightly-report',
{ pattern: '0 3 * * *' },
{ name: 'report', data: { scope: 'daily' } }
);Job Schedulers replaced the older repeatable-jobs API; upserting by a fixed id means redeploys do not create duplicate schedules.
Listen for completion globallyqueue-events
import { QueueEvents } from 'bullmq';
const events = new QueueEvents('emails', { connection });
events.on('completed', ({ jobId }) => console.log('done', jobId));
events.on('failed', ({ jobId, failedReason }) => console.error(jobId, failedReason));QueueEvents uses Redis streams on its own blocking connection; worker.on('completed') only sees jobs handled by that one worker process.
Run a parent job after its childrenflow-parent-child
import { FlowProducer } from 'bullmq';
const flow = new FlowProducer({ connection });
await flow.add({
name: 'assemble-video',
queueName: 'renders',
children: [
{ name: 'transcode', data: { part: 1 }, queueName: 'transcode' },
{ name: 'transcode', data: { part: 2 }, queueName: 'transcode' }
]
});The parent waits in a waiting-children state until every child completes; read child results in the parent via job.getChildrenValues().
Rate limit a queuerate-limit
const worker = new Worker('api-calls', processor, {
connection,
limiter: { max: 10, duration: 1000 }
});The limit applies to the queue as a whole, not per worker instance; per-group rate limiting is a BullMQ Pro feature.
Process many jobs in parallelconcurrency
const worker = new Worker('jobs', processor, {
connection,
concurrency: 50
});Concurrency shares one event loop, so it helps I/O-bound jobs only; CPU-bound work needs sandboxed processors or more worker processes.
Isolate heavy jobs in a child processsandboxed-processor
// worker.js
import { Worker } from 'bullmq';
const worker = new Worker(
'renders',
new URL('./processor.js', import.meta.url),
{ connection }
);
// processor.js
export default async function (job) {
return heavyRender(job.data);
}Passing a file path instead of a function runs jobs in a child process, so a crash or blocking loop cannot take down the worker; the job object inside is a proxy with a reduced API.
Stop finished jobs from filling Rediscleanup-policy
await queue.add('log-event', data, {
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 24 * 3600 }
});Without these options finished jobs accumulate in Redis indefinitely; set them per add or once via the queue defaultJobOptions.
Shut a worker down cleanlygraceful-shutdown
process.on('SIGTERM', async () => {
await worker.close();
await queue.close();
process.exit(0);
});worker.close() waits for active jobs to finish; kill the process instead and those jobs get marked stalled and rerun after the stalled interval.
Deduplicate identical jobsdeduplicate
await queue.add('sync-user', { userId: 7 }, {
deduplication: { id: 'sync-user-7', ttl: 5000 }
});While a job with the same deduplication id exists, further adds are ignored; with a ttl it throttles, without one it holds until the job finishes.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pg-boss | npm | You already run Postgres and would rather not add Redis; SKIP LOCKED queues cover most job workloads |
| bee-queue | npm | You want a minimal, fast Redis queue and can live without repeatable jobs and flows |
| agenda | npm | MongoDB is your only datastore and job volume is modest; check its maintenance activity first |