Home JavaScript Message Queues with BullMQ in Node.js
Advanced 5 min · 2026-07-12

Message Queues with BullMQ in Node.js

Message queues in Node.js with BullMQ: Redis-backed job queues, producer-consumer pattern, delayed jobs, rate limiting, and enterprise microservice communication..

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 15-20 minutes
  • Basic Node.js and JavaScript knowledge. Familiarity with npm and Express.js is recommended.
 ● Production Incident
Quick Answer

BullMQ is the successor to Bull, built for Redis 6+ with updated features for microservice communication. It provides reliable job queues with priority, scheduling, retries with backoff, rate limiting

✦ Definition~90s read
What is Message Queues with BullMQ in Node.js?

BullMQ is the successor to Bull, built for Redis 6+ with updated features for microservice communication. It provides reliable job queues with priority, scheduling, retries with backoff, rate limiting, and concurrency control. In microservice architectures, BullMQ acts as a message broker between services — Service A produces a job, Service B (or multiple workers) consumes it.

Think of a message queue like a restaurant kitchen with a ticket system.

BullMQ supports delayed jobs, repeatable jobs, job progress reporting, and flow producers for creating parent-child job dependencies. Production patterns include separate queues for different job types, dead letter queues for failed jobs, and queue metrics for monitoring worker health.

Plain-English First

Think of a message queue like a restaurant kitchen with a ticket system. The waiter (producer) writes orders and hangs them on a spinning rack. The chefs (consumers) grab tickets one at a time, cook the meal, and toss the ticket. If a chef gets overwhelmed, orders pile up but don't get lost—they just wait. BullMQ is that spinning rack for your Node.js app, ensuring tasks like sending emails or processing images happen reliably without crashing your main server.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

Your monolith is being split into microservices, and the first challenge is communication: Service A needs to notify Service B when a user signs up, but they should never depend on each other being available simultaneously. A message queue decouples them — Service A publishes an event, Service B processes it when ready. BullMQ has become the standard message queue library for Node.js microservices, combining Redis reliability with job-specific features like retries, delays, and rate limiting. This article covers the producer-consumer pattern, job flows, and monitoring production queues.

Why BullMQ for Production Message Queues

BullMQ is a Redis-backed job queue library for Node.js that handles millions of jobs daily in production. Unlike simple in-memory queues, BullMQ provides persistence, delayed jobs, rate limiting, and concurrency control. It's battle-tested at scale, used by companies like Slack and Discord. The key differentiator is its use of Redis streams for reliability—jobs survive crashes and restarts. For any system requiring background processing (email, image processing, webhooks), BullMQ is the go-to choice. Avoid alternatives like Kue (unmaintained) or Bee-Queue (limited features). BullMQ's maturity and active development make it production-ready.

install.shBASH
1
npm install bullmq ioredis
Output
+ bullmq@5.12.0
+ ioredis@5.4.1
🔥Redis Version
BullMQ requires Redis >= 6.2.0 for streams support. Check with redis-server --version.
📊 Production Insight
In production, always use a Redis cluster or sentinel for high availability. A single Redis instance is a single point of failure.
🎯 Key Takeaway
BullMQ is the standard for production-grade job queues in Node.js, offering persistence and reliability via Redis streams.
message-queues-bullmq THECODEFORGE.IO BullMQ System Architecture Components for production message queuing Application Layer Job Producer | Job Consumer Queue Management Queue Instance | Job Scheduler | Rate Limiter Worker Layer Worker Pool | Concurrency Controller | Retry Handler Persistence Layer Redis Backend | Job Data Store | Failed Job Log Observability Bull Board UI | Metrics Collector | Event Emitter THECODEFORGE.IO
thecodeforge.io
Message Queues Bullmq

Setting Up a Queue and Worker

A BullMQ queue holds jobs, and workers process them. The queue is a Redis-backed data structure; workers are Node.js processes that poll the queue. Here's a minimal setup: create a queue with a name, then a worker that processes jobs. The worker's concurrency option controls how many jobs run in parallel. Always handle errors in the worker—uncaught exceptions crash the process. Use the failed event for monitoring. The queue can also be used to add jobs with options like delay, priority, and attempts.

queue-setup.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { Queue, Worker } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis({ host: 'localhost', port: 6379, maxRetriesPerRequest: null });

const emailQueue = new Queue('email', { connection });

const worker = new Worker('email', async job => {
  console.log(`Processing job ${job.id}: ${job.data.to}`);
  // Simulate email send
  await sendEmail(job.data);
}, { connection, concurrency: 5 });

worker.on('completed', job => console.log(`Job ${job.id} completed`));
worker.on('failed', (job, err) => console.error(`Job ${job.id} failed: ${err.message}`));

async function sendEmail(data) {
  // actual email sending logic
  return true;
}

module.exports = { emailQueue, worker };
Output
Processing job 1: user@example.com
Job 1 completed
Try it live
⚠ Connection Handling
Set maxRetriesPerRequest: null to avoid blocking the worker when Redis is temporarily down.
📊 Production Insight
Run workers in a separate process or container from the web server to isolate failures and allow independent scaling.
🎯 Key Takeaway
Queues and workers are separate concerns; workers should be stateless and idempotent.

Adding Jobs with Options

Jobs can be added with various options to control execution: delay for scheduled tasks, attempts for retry logic, backoff for exponential backoff, priority for ordering, and removeOnComplete to auto-clean. Use jobId for idempotency—if a job with the same ID exists, it won't be duplicated. This is critical for preventing duplicate payments or emails. Always set a reasonable attempts count (e.g., 3) with exponential backoff to handle transient failures.

add-jobs.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
const { emailQueue } = require('./queue-setup');

async function addEmailJob(to, subject, body) {
  await emailQueue.add('send-email', { to, subject, body }, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 2000 },
    removeOnComplete: { age: 3600, count: 1000 },
    jobId: `email:${to}:${Date.now()}`,
  });
}

addEmailJob('user@example.com', 'Welcome!', 'Hello...');
Output
Job added with id: email:user@example.com:1712345678
Try it live
💡Idempotency Keys
Use a deterministic jobId (e.g., based on business key) to prevent duplicate jobs. BullMQ will skip adding if the ID exists.
📊 Production Insight
Set removeOnComplete to avoid unbounded queue growth. In production, a queue with millions of completed jobs can degrade Redis performance.
🎯 Key Takeaway
Job options like delay, attempts, and backoff are essential for resilient scheduling.
message-queues-bullmq THECODEFORGE.IO BullMQ System Architecture Components for production message queuing Client Layer Node.js App | Queue Instance | Job Producer Queue Layer BullMQ Queue | Job Options | Rate Limiter Storage Layer Redis Server | Job Data | Failed Jobs Worker Layer Worker Instance | Job Processor | Concurrency Control Observability Queue Events | Metrics | Dashboard THECODEFORGE.IO
thecodeforge.io
Message Queues Bullmq

Handling Job Failures and Retries

BullMQ automatically retries failed jobs based on the attempts option. The worker's failed event fires after all retries are exhausted. For transient errors (e.g., network timeout), retry with backoff. For permanent errors (e.g., invalid data), mark the job as failed immediately by throwing a specific error. Use the removeOnFail option to auto-clean failed jobs. Monitor failed jobs via a dashboard or alerts. Never silently swallow errors—always log them.

worker-with-retries.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { Worker } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis();

const worker = new Worker('email', async job => {
  if (!job.data.to.includes('@')) {
    throw new Error('Invalid email'); // permanent failure, no retry
  }
  // Simulate transient failure
  if (Math.random() < 0.3) throw new Error('Network timeout');
  console.log(`Sent to ${job.data.to}`);
}, { connection, concurrency: 10 });

worker.on('failed', (job, err) => {
  console.error(`Job ${job.id} failed after ${job.attemptsMade} attempts: ${err.message}`);
});
Output
Job 2 failed after 3 attempts: Network timeout
Try it live
⚠ Permanent vs Transient
Throw a custom error class for permanent failures to avoid unnecessary retries. BullMQ doesn't distinguish by default.
📊 Production Insight
Set up alerts on failed jobs (e.g., via webhook to PagerDuty). A sudden spike in failures often indicates a deployment bug or external service outage.
🎯 Key Takeaway
Distinguish between transient and permanent failures to avoid wasting retries on invalid data.

Rate Limiting and Concurrency Control

BullMQ supports per-queue rate limiting to avoid overwhelming downstream services. Use the limiter option on the queue: max (max jobs per interval) and duration (interval in ms). For example, limit to 10 jobs per second. Concurrency on the worker controls how many jobs run in parallel. Combine both: set worker concurrency high, and let the limiter throttle. This is crucial for APIs with rate limits (e.g., SendGrid, Twilio).

rate-limited-queue.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { Queue, Worker } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis();

const emailQueue = new Queue('email', {
  connection,
  limiter: { max: 10, duration: 1000 } // 10 jobs per second
});

const worker = new Worker('email', async job => {
  // send email
}, { connection, concurrency: 20 });

// Add jobs as usual
Output
Jobs processed at max 10/sec
Try it live
🔥Limiter vs Concurrency
Limiter throttles the rate jobs are pulled from the queue; concurrency limits how many run simultaneously. Use both for fine-grained control.
📊 Production Insight
Monitor queue length and limiter delays. If the queue grows indefinitely, your limiter is too strict or workers are too slow.
🎯 Key Takeaway
Rate limiting protects downstream services from being overwhelmed by job bursts.

Scheduled and Delayed Jobs

BullMQ supports delayed jobs via the delay option (in ms). For recurring schedules (e.g., daily report), use the QueueScheduler or a separate cron worker. BullMQ doesn't have built-in cron; use node-cron or bullmq's repeatable jobs. Repeatable jobs are added with a repeat option specifying a cron pattern. They are stored in Redis and re-added automatically. Be careful with timezone—cron patterns are UTC by default.

scheduled-jobs.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { Queue } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis();
const reportQueue = new Queue('reports', { connection });

// Add a repeatable job every day at midnight UTC
async function scheduleDailyReport() {
  await reportQueue.add('daily-report', { type: 'sales' }, {
    repeat: { pattern: '0 0 * * *' },
    jobId: 'daily-sales-report'
  });
}

scheduleDailyReport();
Output
Repeatable job added: daily-sales-report
Try it live
⚠ Repeatable Job Idempotency
Always set a jobId for repeatable jobs to prevent duplicates on worker restart. BullMQ uses the ID to deduplicate.
📊 Production Insight
For time-sensitive schedules, consider using a separate scheduler service (e.g., AWS EventBridge) to trigger BullMQ jobs, as BullMQ's repeatable jobs may drift under heavy load.
🎯 Key Takeaway
Use repeatable jobs for cron-like scheduling; they persist across restarts.

Monitoring and Observability

BullMQ provides events (completed, failed, progress, waiting) for real-time monitoring. Use Queue#getJobs to inspect queue state. For production, integrate with dashboards like Bull Board (a UI for Bull/BullMQ). Log job lifecycle events with structured logging (e.g., Pino). Track metrics: queue depth, processing time, failure rate. Use Redis INFO to monitor memory. Set up health checks: if queue depth exceeds threshold, alert.

monitoring.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { Queue } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis();
const emailQueue = new Queue('email', { connection });

async function monitor() {
  const [waiting, active, completed, failed] = await Promise.all([
    emailQueue.getWaitingCount(),
    emailQueue.getActiveCount(),
    emailQueue.getCompletedCount(),
    emailQueue.getFailedCount()
  ]);
  console.log({ waiting, active, completed, failed });
}

setInterval(monitor, 5000);
Output
{ waiting: 12, active: 5, completed: 1024, failed: 3 }
Try it live
💡Bull Board
Install @bull-board/express for a web UI to manage queues, retry failed jobs, and view job data.
📊 Production Insight
Set up Prometheus metrics export from BullMQ (e.g., via bullmq-prometheus) to integrate with Grafana dashboards.
🎯 Key Takeaway
Real-time monitoring of queue metrics is essential for detecting bottlenecks and failures early.

Graceful Shutdown and Job Persistence

When shutting down a worker, you must wait for active jobs to complete to avoid losing work. BullMQ's worker.close() returns a promise that resolves after all active jobs finish. Use process signals (SIGTERM, SIGINT) to trigger graceful shutdown. For queues, persistence is automatic via Redis. However, if Redis goes down, jobs may be lost if not persisted to disk. Configure Redis appendonly yes for durability. In Kubernetes, use preStop hooks to drain workers.

graceful-shutdown.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { Worker } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis();
const worker = new Worker('email', async job => {
  // process
}, { connection });

async function shutdown() {
  console.log('Shutting down worker...');
  await worker.close();
  await connection.quit();
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
Output
Shutting down worker...
(waits for active jobs to complete)
Try it live
⚠ Redis Persistence
Enable AOF persistence in Redis (appendonly yes) to survive crashes. Without it, a Redis restart loses all jobs.
📊 Production Insight
In containerized environments, set a generous terminationGracePeriodSeconds (e.g., 120s) to allow long-running jobs to finish.
🎯 Key Takeaway
Graceful shutdown ensures no jobs are lost during deployments or scaling events.

Scaling Workers Horizontally

BullMQ workers are stateless and can be scaled horizontally by running multiple instances. Each worker polls the same Redis queue. Use the concurrency option per worker to control parallelism. For high throughput, run many worker processes (e.g., one per CPU core). Use a process manager like PM2 or Kubernetes to manage worker instances. Be aware of Redis connection limits—each worker uses one connection. Use connection pooling with ioredis Cluster for large deployments.

cluster-workers.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
  const numWorkers = os.cpus().length;
  console.log(`Forking ${numWorkers} workers`);
  for (let i = 0; i < numWorkers; i++) {
    cluster.fork();
  }
  cluster.on('exit', (worker) => {
    console.log(`Worker ${worker.process.pid} died, restarting`);
    cluster.fork();
  });
} else {
  require('./worker'); // worker code
}
Output
Forking 4 workers
Worker 1234 started
Worker 1235 started
...
Try it live
🔥Redis Connection Limits
Each worker opens a Redis connection. Ensure your Redis server's maxclients is high enough (default 10000).
📊 Production Insight
Use Kubernetes HPA (Horizontal Pod Autoscaler) based on queue depth metric to auto-scale workers.
🎯 Key Takeaway
Horizontal scaling of workers is straightforward due to shared Redis backend.

Advanced: Job Dependencies and Flows

BullMQ supports job dependencies via the parent option or using flows. A flow is a set of jobs with dependencies—a parent job waits for children to complete. This is useful for complex workflows like order processing (payment -> inventory -> shipping). Use QueueFlow to create flows. Children jobs can be added with children option. The parent job completes only after all children succeed. If any child fails, the parent fails. This pattern replaces ad-hoc orchestration.

job-flow.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { QueueFlow } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis();
const flow = new QueueFlow(connection);

async function createOrderFlow(orderId) {
  const parent = await flow.add('order', { orderId }, {
    children: [
      { name: 'payment', data: { orderId }, queueName: 'payment' },
      { name: 'inventory', data: { orderId }, queueName: 'inventory' }
    ]
  });
  console.log(`Parent job ${parent.id} created`);
}

createOrderFlow('123');
Output
Parent job 42 created
Try it live
💡Flow Limitations
Flows are limited to one level of children. For deeper dependencies, chain flows or use a workflow engine like Temporal.
📊 Production Insight
Use flows for idempotent workflows. If a parent job is retried, children are not re-executed if they already completed.
🎯 Key Takeaway
Job flows enable complex, dependent job chains without external orchestration.

Production Pitfalls and Best Practices

Common pitfalls: forgetting to set removeOnComplete leading to Redis memory bloat; not handling worker crashes (use stalledInterval to detect stalled jobs); using default Redis config without persistence; not monitoring queue depth. Best practices: always use environment variables for Redis connection; set stalledInterval to a reasonable value (default 30s); use jobId for idempotency; log job payloads (but sanitize PII); test failure scenarios with chaos engineering.

best-practices.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const { Queue, Worker } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  password: process.env.REDIS_PASSWORD,
  maxRetriesPerRequest: null,
  enableReadyCheck: false
});

const queue = new Queue('myqueue', {
  connection,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: 'exponential', delay: 1000 },
    removeOnComplete: { age: 86400, count: 10000 },
    removeOnFail: { age: 604800 }
  }
});

const worker = new Worker('myqueue', async job => {
  // process
}, { connection, stalledInterval: 30000 });
Try it live
⚠ Stalled Jobs
If a worker crashes while processing a job, BullMQ marks it as stalled after stalledInterval. Set this lower than your job's max runtime.
📊 Production Insight
Run load tests with simulated Redis failures to ensure your queue system degrades gracefully and doesn't lose jobs.
🎯 Key Takeaway
Production readiness requires careful configuration of Redis persistence, job cleanup, and stall detection.

Conclusion: BullMQ in the Real World

BullMQ is a robust, feature-rich queue system that scales from a single process to a cluster of workers. Its Redis-backed design provides persistence, reliability, and performance. By following the patterns in this article—proper job options, error handling, monitoring, and graceful shutdown—you can build production-grade background job systems. Remember: queues are not just for async tasks; they decouple components and improve resilience. Start simple, add features as needed, and always monitor.

final-example.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Full production setup
const { Queue, Worker, QueueScheduler } = require('bullmq');
const Redis = require('ioredis');

const connection = new Redis(process.env.REDIS_URL);

const queue = new Queue('tasks', { connection });
const scheduler = new QueueScheduler('tasks', { connection });

const worker = new Worker('tasks', async job => {
  // process
}, { connection, concurrency: 10 });

worker.on('completed', job => console.log(`Job ${job.id} done`));
worker.on('failed', (job, err) => console.error(`Job ${job.id} failed: ${err.message}`));

async function shutdown() {
  await worker.close();
  await scheduler.close();
  await queue.close();
  await connection.quit();
}

process.on('SIGTERM', shutdown);
Try it live
🔥QueueScheduler
Always create a QueueScheduler instance for each queue to handle delayed and repeatable jobs correctly.
📊 Production Insight
Consider using BullMQ Pro (paid) for advanced features like sandboxed workers and job observability if your needs outgrow the open-source version.
🎯 Key Takeaway
BullMQ is production-ready; invest time in monitoring and failure handling to reap its benefits.

Bull Board UI Dashboard

Bull Board is a UI dashboard for monitoring and managing Bull and BullMQ queues. It provides real-time visibility into job statuses, retries, and queue metrics. To integrate, install @bull-board/api and @bull-board/express. Mount the router on your Express app. You can customize authentication, queue display, and locale. For production, secure the dashboard behind an auth proxy or use the built-in middleware. Bull Board supports multiple queues, job data inspection, and manual job retries or removal. It's invaluable for debugging and operations.

bull-board-setup.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const { createBullBoard } = require('@bull-board/api');
const { BullMQAdapter } = require('@bull-board/api/bullMQAdapter');
const { ExpressAdapter } = require('@bull-board/express');
const { Queue } = require('bullmq');

const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');

const queue = new Queue('myQueue', { connection });

createBullBoard({
  queues: [new BullMQAdapter(queue)],
  serverAdapter,
});

app.use('/admin/queues', serverAdapter.getRouter());
Try it live
⚠ Security First
Never expose Bull Board to the public internet without authentication. Use a reverse proxy with basic auth or integrate with your existing auth system.
📊 Production Insight
In production, restrict access to Bull Board via VPN or authentication middleware to prevent unauthorized queue manipulation.
🎯 Key Takeaway
Bull Board provides a real-time UI for queue monitoring and management, essential for production observability.

Queue Pausing and Draining

BullMQ allows pausing and draining queues for maintenance or graceful shutdown. Pausing a queue prevents new jobs from being processed but keeps them in the queue. Draining removes all jobs (waiting, delayed, active) from the queue. Use queue.pause() and queue.drain() with optional delayed flag. Draining is destructive; use with caution. Pausing is useful for deployments: pause workers, let active jobs finish, then deploy new code. Resume with queue.resume(). For draining, consider moving jobs to a dead letter queue first.

pause-drain.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { Queue } = require('bullmq');
const queue = new Queue('myQueue', { connection });

// Pause queue (prevents new jobs from being processed)
await queue.pause();

// Drain all jobs (waiting, delayed, active)
await queue.drain();

// Drain only delayed jobs
await queue.drain({ delayed: true });

// Resume queue
await queue.resume();
Try it live
💡Drain with Care
Draining is irreversible. Always back up job data or move jobs to a dead letter queue before draining.
📊 Production Insight
Use pausing during rolling deployments to ensure zero job loss: pause, wait for active jobs to finish, deploy, then resume.
🎯 Key Takeaway
Pausing and draining queues are critical for controlled deployments and maintenance without data loss.

OpenTelemetry Integration with BullMQ

OpenTelemetry provides observability through traces and metrics. BullMQ supports OpenTelemetry via the @opentelemetry/instrumentation-bullmq package. It automatically creates spans for job processing, queue operations, and worker lifecycle. Integrate with your existing OpenTelemetry setup (e.g., Jaeger, Zipkin, or cloud providers). This enables distributed tracing across microservices, showing how jobs flow through queues. To use, install the instrumentation and register it before creating queues or workers. Configure sampling rates to control overhead. Traces include job ID, queue name, and attempt number.

opentelemetry-setup.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
const { BullMQInstrumentation } = require('@opentelemetry/instrumentation-bullmq');

const provider = new NodeTracerProvider();
provider.register();

registerInstrumentations({
  instrumentations: [
    new BullMQInstrumentation({
      // Optional: hook to enrich spans
      requestHook: (span, job) => {
        span.setAttribute('job.id', job.id);
      },
    }),
  ],
});

// Now create queues and workers as usual
Try it live
🔥Trace Sampling
For high-throughput queues, use sampling to reduce trace volume. Set sampler on the tracer provider to AlwaysOff or a rate-based sampler.
📊 Production Insight
Combine OpenTelemetry traces with Bull Board metrics for a complete observability stack: traces for individual jobs, metrics for aggregate health.
🎯 Key Takeaway
OpenTelemetry integration gives end-to-end visibility into job processing, essential for debugging distributed systems.

Dead Letter Queue Patterns

A dead letter queue (DLQ) stores jobs that have failed after exhausting retries. BullMQ doesn't have a built-in DLQ, but you can implement one easily. After max retries, move the failed job to a separate queue. Use the worker's failed event or a custom handler. Store the original job data and error details. Optionally, set up a separate worker to process the DLQ for manual inspection or reprocessing. This prevents poison messages from blocking the main queue. For critical jobs, alert when a job enters the DLQ.

dead-letter-queue.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const { Queue, Worker } = require('bullmq');

const mainQueue = new Queue('main', { connection });
const dlq = new Queue('dlq', { connection });

const worker = new Worker('main', async job => {
  // process job
}, {
  connection,
  maxStalledCount: 3,
});

worker.on('failed', async (job, err) => {
  if (job.attemptsMade >= job.opts.attempts) {
    await dlq.add(job.name, job.data, {
      jobId: job.id,
      attempts: 1, // retry DLQ processing once
    });
  }
});

// DLQ worker for manual reprocessing
const dlqWorker = new Worker('dlq', async job => {
  // Inspect and decide to reprocess or discard
  console.error('DLQ job:', job.id, job.data);
  // Optionally re-add to main queue
  await mainQueue.add(job.name, job.data);
}, { connection });
Try it live
⚠ Avoid Infinite Loops
When reprocessing from DLQ, ensure you don't re-add jobs that will immediately fail again. Add a counter or use a separate queue with limited retries.
📊 Production Insight
Monitor DLQ size and set up alerts. A growing DLQ indicates systemic issues that need attention.
🎯 Key Takeaway
Implement a dead letter queue to isolate permanently failed jobs and prevent queue clogging.

Unit Testing Workers

Unit testing BullMQ workers requires isolating job processing logic from Redis. Use bullmq's Job class to create mock jobs. Instantiate a worker with a mock connection or use ioredis-mock. For pure logic, extract the processor function and test it directly. Test job options, error handling, and retries. Use jest or mocha. For integration tests, use a real Redis instance (e.g., via Docker) but keep tests isolated. Mock the Worker constructor to avoid connecting to Redis in unit tests.

worker-test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const { Job } = require('bullmq');
const { processJob } = require('./worker');

jest.mock('bullmq', () => ({
  ...jest.requireActual('bullmq'),
  Worker: jest.fn().mockImplementation(() => ({
    on: jest.fn(),
    close: jest.fn(),
  })),
}));

describe('processJob', () => {
  it('should process job data correctly', async () => {
    const data = { userId: 123 };
    const job = new Job({
      queue: { name: 'test' },
      data,
      opts: { attempts: 3 },
    });
    const result = await processJob(job);
    expect(result).toEqual({ processed: true });
  });

  it('should throw on invalid data', async () => {
    const job = new Job({
      queue: { name: 'test' },
      data: {},
      opts: { attempts: 1 },
    });
    await expect(processJob(job)).rejects.toThrow('Invalid data');
  });
});
Try it live
💡Test Retries
Test that your worker handles retries correctly by simulating failures and verifying the job's attemptsMade and failedReason.
📊 Production Insight
Always test worker error handling and retry logic. A bug here can cause silent data loss or infinite retries.
🎯 Key Takeaway
Unit test workers by isolating job processing logic from Redis, using mock jobs and connection mocks.
BullMQ vs Basic Redis Queue Production features for reliability and scaling BullMQ Basic Redis Queue Job Retry Logic Built-in with exponential backoff Manual implementation required Rate Limiting Configurable per queue Not supported natively Delayed Jobs Delay option in job options Requires custom scheduling Observability Events and metrics API No built-in monitoring Graceful Shutdown Worker.close() with pending jobs Manual cleanup needed Persistence Redis-backed with job data Depends on Redis configuration THECODEFORGE.IO
thecodeforge.io
Message Queues Bullmq

Concurrency-Per-Worker Configuration

BullMQ workers accept a concurrency option that controls how many jobs a single worker processes in parallel. Default is 1. Increase concurrency for I/O-bound tasks (e.g., API calls, file reads). For CPU-bound tasks, keep concurrency low to avoid starving the event loop. Monitor CPU and memory usage to find the sweet spot. Use os.cpus().length as a starting point for CPU-bound tasks. For I/O-bound, you can go higher (e.g., 10-25). Each concurrent job uses a separate async context, so ensure your code is thread-safe (no shared mutable state).

concurrency-worker.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { Worker } = require('bullmq');
const os = require('os');

const worker = new Worker('myQueue', async job => {
  // I/O-bound task
  const result = await fetch(`https://api.example.com/data/${job.data.id}`);
  return result.json();
}, {
  connection,
  concurrency: 10, // process up to 10 jobs in parallel
});

// For CPU-bound, use os.cpus().length
const cpuWorker = new Worker('cpuQueue', async job => {
  // CPU-intensive work
  return heavyComputation(job.data);
}, {
  connection,
  concurrency: os.cpus().length,
});
Try it live
⚠ Watch Memory
High concurrency can lead to memory spikes if each job holds large data. Monitor heap usage and adjust accordingly.
📊 Production Insight
Start with concurrency=1 and increase gradually while monitoring latency and resource usage. Use load testing to find optimal values.
🎯 Key Takeaway
Tune concurrency per worker based on workload type: high for I/O, low for CPU-bound tasks.
● Production incidentPOST-MORTEMseverity: high

The Case of the Disappearing Jobs: How Redis Memory Eviction Killed Our Queue

Symptom
Jobs were being added to the queue but never processed. No errors in the worker logs. Queue size in Bull Board showed 0, but we knew jobs were being added.
Assumption
We assumed a bug in our job producer or a network issue causing jobs to not reach Redis.
Root cause
Redis was configured with maxmemory-policy allkeys-lru. When memory filled up, Redis evicted keys including BullMQ's queue data structures (lists, sorted sets). The jobs were added but immediately evicted before workers could fetch them.
Fix
Changed Redis eviction policy to noeviction for the queue database, and added memory alerts. Also set maxmemory to a safe limit and monitored memory usage. For BullMQ, we enabled removeOnComplete and removeOnFail to auto-clean old jobs.
Key lesson
  • Never use LRU eviction on a Redis instance used for BullMQ queues; it will silently drop jobs.
  • Always monitor Redis memory usage and set appropriate maxmemory and eviction policies.
  • Enable job removal on completion/failure to prevent unbounded queue growth.
  • Test queue behavior under memory pressure in staging before production.
⚙ Quick Reference
18 commands from this guide
FileCommand / CodePurpose
install.shnpm install bullmq ioredisWhy BullMQ for Production Message Queues
queue-setup.jsconst { Queue, Worker } = require('bullmq');Setting Up a Queue and Worker
add-jobs.jsconst { emailQueue } = require('./queue-setup');Adding Jobs with Options
worker-with-retries.jsconst { Worker } = require('bullmq');Handling Job Failures and Retries
rate-limited-queue.jsconst { Queue, Worker } = require('bullmq');Rate Limiting and Concurrency Control
scheduled-jobs.jsconst { Queue } = require('bullmq');Scheduled and Delayed Jobs
monitoring.jsconst { Queue } = require('bullmq');Monitoring and Observability
graceful-shutdown.jsconst { Worker } = require('bullmq');Graceful Shutdown and Job Persistence
cluster-workers.jsconst cluster = require('cluster');Scaling Workers Horizontally
job-flow.jsconst { QueueFlow } = require('bullmq');Advanced
best-practices.jsconst { Queue, Worker } = require('bullmq');Production Pitfalls and Best Practices
final-example.jsconst { Queue, Worker, QueueScheduler } = require('bullmq');Conclusion
bull-board-setup.jsconst { createBullBoard } = require('@bull-board/api');Bull Board UI Dashboard
pause-drain.jsconst { Queue } = require('bullmq');Queue Pausing and Draining
opentelemetry-setup.jsconst { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');OpenTelemetry Integration with BullMQ
dead-letter-queue.jsconst { Queue, Worker } = require('bullmq');Dead Letter Queue Patterns
worker-test.jsconst { Job } = require('bullmq');Unit Testing Workers
concurrency-worker.jsconst { Worker } = require('bullmq');Concurrency-Per-Worker Configuration

Key takeaways

1
BullMQ Basics
BullMQ is a Redis-backed job queue for Node.js that provides persistence, retries, and concurrency control, making it ideal for production background processing.
2
Job Configuration
Use options like attempts, backoff, delay, and jobId to build resilient and idempotent job workflows.
3
Monitoring and Scaling
Monitor queue metrics and scale workers horizontally; use Bull Board and Prometheus for observability.
4
Production Readiness
Enable Redis persistence, handle graceful shutdown, and test failure scenarios to ensure reliability in production.
5
Bull Board UI Dashboard
Integrate Bull Board for real-time queue monitoring and management. Secure it behind authentication in production.
6
Dead Letter Queue Pattern
Implement a DLQ to isolate permanently failed jobs. Monitor DLQ size and set alerts for systemic issues.
7
Concurrency-Per-Worker Tuning
Set concurrency based on workload: high for I/O-bound tasks, low for CPU-bound. Start low and increase gradually while monitoring resources.
8
Bull Board UI Dashboard
Integrate Bull Board for real-time queue monitoring, but always secure it with authentication in production.
9
Dead Letter Queue Pattern
Implement a separate queue for permanently failed jobs using the failed event to prevent data loss and enable offline analysis.
10
Concurrency-Per-Worker Configuration
Tune the concurrency option per worker based on workload: high for I/O-bound, low for CPU-bound tasks.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does BullMQ handle job retries and what is the default retry strateg...
Q02JUNIOR
Explain the difference between a queue, a worker, and a job in BullMQ.
Q03SENIOR
How would you implement a priority queue in BullMQ?
Q04SENIOR
What are the common failure modes when using BullMQ in production and ho...
Q05JUNIOR
How does BullMQ handle job concurrency and what is the default concurren...
Q06SENIOR
Describe how you would monitor BullMQ queues in production.
Q01 of 06SENIOR

How does BullMQ handle job retries and what is the default retry strategy?

ANSWER
BullMQ uses a built-in retry mechanism. When a job fails, it is automatically retried based on the retryStrategy option. The default strategy is exponential backoff with a delay of 2 seconds for the first retry, then 4, 8, etc., up to a maximum delay of 30 seconds. You can override this by providing a custom function.
FAQ · 12 QUESTIONS

Frequently Asked Questions

01
What is the difference between BullMQ and Bull?
02
How do I handle duplicate jobs?
03
Can I use BullMQ with a Redis cluster?
04
How do I debug a job that keeps failing?
05
What is the best way to monitor BullMQ in production?
06
How do I ensure jobs are not lost on worker crash?
07
How do I integrate BullMQ with OpenTelemetry for distributed tracing?
08
What is a dead letter queue and how do I implement one in BullMQ?
09
How can I unit test a BullMQ worker without connecting to Redis?
10
How do I secure Bull Board in production?
11
What's the difference between drain and obliterate?
12
Can I use OpenTelemetry with BullMQ without the @bullmq/opentelemetry package?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
🔥

That's Node.js. Mark it forged?

5 min read · try the examples if you haven't

Previous
Background Jobs in Node.js with node-cron and Bull
37 / 47 · Node.js
Next
Caching with Redis in Node.js