Skip to content
advanced Phase 12 · Advanced Backend Patterns

Message Queues & Background Jobs

Implement background jobs with Bull/BullMQ, Redis queues, and task processing patterns.

1h 15m
0 problems
Topic Progress 0%

Message Queue Fundamentals

What are Message Queues?

A message queue is a middleware component that allows services to communicate asynchronously by passing messages between producers and consumers. Instead of a service calling another service directly and waiting for a response, it pushes a message onto a queue and continues processing. A consumer picks up the message when ready.

Why Use Queues?

Synchronous request-response chains create tight coupling and bottleneck at the slowest service. If your API calls a payment processor, sends an email, and generates a PDF synchronously, the user waits for all three. Queues decouple these concerns:

  • Temporal decoupling: Producer and consumer don't need to be online simultaneously
  • Load leveling: Absorb traffic spikes — the queue buffers requests and processes them at a manageable rate
  • Fault isolation: If the email service is down, other operations continue unaffected
  • Scalability: Add more consumers horizontally to increase throughput without changing the producer
// Synchronous (bad) — user waits for all operations
app.post('/api/orders', async (req, res) => {
  const order = await createOrder(req.body);
  await sendConfirmationEmail(order);    // 200ms
  await chargePayment(order);             // 500ms
  await generateInvoice(order);           // 300ms
  res.json(order); // User waits ~1000ms
});

// Asynchronous (good) — user gets immediate response
app.post('/api/orders', async (req, res) => {
  const order = await createOrder(req.body);
  await queue.add('send-email', { orderId: order.id });
  await queue.add('charge-payment', { orderId: order.id });
  await queue.add('generate-invoice', { orderId: order.id });
  res.json(order); // User waits ~50ms
});

Queue Topologies

Topology Description Use Case
Point-to-point One producer, one consumer per message Task distribution, work queues
Pub/Sub One producer, multiple consumers receive copies Event broadcasting, notifications
Request-Reply Producer waits for consumer's response on a reply queue RPC-style async operations
Pipeline Output of one queue feeds into the next Multi-stage data processing

BullMQ vs Bull

BullMQ is the successor to Bull, rewritten with a focus on TypeScript, Redis Cluster support, and better performance. Bull is deprecated but still widely used. For new projects, always use BullMQ.

// BullMQ setup
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: 6379,
  maxRetriesPerRequest: null, // Required by BullMQ
});

const emailQueue = new Queue('emails', { connection });
const paymentQueue = new Queue('payments', { connection });

Bull/BullMQ Setup & Job Types

Setting Up Bull/BullMQ

BullMQ requires a Redis instance as its backing store. Every queue, job, and state is persisted in Redis, which means queues survive application restarts and work across multiple processes.

Job Types

BullMQ supports several job types to cover different scheduling needs:

  • Regular jobs: Added and processed immediately in FIFO order
  • Delayed jobs: Processed after a specified delay period
  • Repeatable jobs: Automatically re-added on a cron schedule or fixed interval
  • Rate-limited jobs: Process at most N jobs within a time window
  • Priority jobs: Higher priority jobs are processed before lower ones
import { Queue, QueueScheduler } from 'bullmq';

const connection = new Redis({ host: '127.0.0.1', port: 6379, maxRetriesPerRequest: null });
const emailQueue = new Queue('emails', { connection });

// 1. Regular job — processed immediately
await emailQueue.add('send-welcome', {
  userId: 42,
  template: 'welcome',
  to: 'user@example.com',
});

// 2. Delayed job — processed after 5 minutes (in milliseconds)
await emailQueue.add('send-followup', {
  userId: 42,
  template: 'followup',
}, {
  delay: 5 * 60 * 1000, // 5 minutes
});

// 3. Repeatable job — runs every day at 9 AM
await emailQueue.add('send-daily-digest', {
  type: 'daily-digest',
}, {
  repeat: {
    cron: '0 9 * * *',
    tz: 'America/New_York',
  },
});

// 4. Rate-limited — at most 5 jobs per minute
await emailQueue.addBulk(
  recipients.map((email, i) => ({
    name: 'send-newsletter',
    data: { to: email, campaignId: 123 },
    opts: {
      jobId: `newsletter-${campaignId}-${i}`,
    },
  })),
);

// 5. Priority — urgent emails processed first
await emailQueue.add('send-notification', {
  to: 'admin@example.com',
  message: 'Server alert',
}, {
  priority: 1, // Lower number = higher priority
});
await emailQueue.add('send-receipt', {
  to: 'user@example.com',
  orderId: 456,
}, {
  priority: 10, // Lower priority
});

Global Rate Limiting with QueueScheduler

To enforce rate limits across all producers (not just per-add), use a QueueScheduler:

import { QueueScheduler } from 'bullmq';

const scheduler = new QueueScheduler('emails', { connection });
await scheduler.waitUntilReady();

const emailQueue = new Queue('emails', {
  connection,
  limiter: {
    max: 100,         // max jobs
    duration: 60_000,  // per 60 seconds
  },
});

// All adds are now rate-limited globally
for (let i = 0; i < 500; i++) {
  await emailQueue.add('send', { to: `user${i}@example.com` });
}

Job Options Summary

Option Type Description
delay number Milliseconds to wait before processing
attempts number Number of times to retry on failure
backoff object Retry delay strategy ({ type, delay })
priority number Lower number = higher priority
removeOnComplete boolean/object Auto-remove job after completion
removeOnFail boolean/object Auto-remove job after failure
jobId string Custom unique job ID
stackTraceLimit number Max stack trace lines stored on failure

Worker Processing & Error Handling

Workers: The Consumer Side

A Worker connects to Redis, pulls jobs from the queue, and executes them. Workers run in separate processes or threads, keeping the main application responsive.

Basic Worker Setup

import { Worker } from 'bullmq';

const emailWorker = new Worker('emails', async (job) => {
  console.log(`Processing email job ${job.id}: ${job.name}`);
  console.log(`To: ${job.data.to}, Template: ${job.data.template}`);

  // Simulate email sending
  await sendEmail({
    to: job.data.to,
    template: job.data.template,
    data: job.data.payload,
  });

  // Return value is stored as job.returnvalue
  return { sent: true, messageId: 'abc-123' };
}, {
  connection: new Redis({ host: '127.0.0.1', port: 6379, maxRetriesPerRequest: null }),
  concurrency: 5,       // Process 5 jobs simultaneously
  limiter: {
    max: 10,            // Max 10 jobs...
    duration: 10_000,   // ...per 10 seconds
  },
});

// Event handlers
emailWorker.on('completed', (job, result) => {
  console.log(`Job ${job.id} completed:`, result);
});

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

emailWorker.on('stalled', (jobId) => {
  console.warn(`Job ${jobId} stalled — possibly crashed during processing`);
});

Retries and Backoff

Transient failures (network timeouts, rate limits) are expected. BullMQ retries jobs with configurable backoff strategies:

const orderWorker = new Worker('orders', async (job) => {
  const result = await chargePayment(job.data.paymentMethodId, job.data.amount);
  return result;
}, {
  connection,
  concurrency: 3,
});

// Jobs are added with retry configuration
await ordersQueue.add('charge', {
  paymentMethodId: 'pm_card_visa',
  amount: 9999,
}, {
  attempts: 4, // Retry up to 4 times total (original + 3 retries)
  backoff: {
    type: 'exponential', // Doubling delay: 2s, 4s, 8s
    delay: 2000,         // Initial delay in ms
  },
});

// Alternative backoff types:
// { type: 'fixed', delay: 5000 }      — Always wait 5 seconds
// { type: 'exponential', delay: 1000 } — 1s, 2s, 4s, 8s...
// Custom function for advanced scenarios:
backoff: {
  type: 'custom',
  delay: (attemptsMade, err) => {
    // Custom logic: longer delay for payment errors
    if (err.code === 'RATE_LIMITED') return 30_000;
    return Math.min(1000 * 2 ** attemptsMade, 60_000);
  },
}

Error Handling and Dead Letter Queues

When a job fails all retry attempts, it moves to the failed state. A Dead Letter Queue (DLQ) captures these failed jobs for later inspection:

import { QueueEvents } from 'bullmq';

const dlq = new Queue('dead-letter', { connection });
const queueEvents = new QueueEvents('orders', { connection });

queueEvents.on('failed', async ({ jobId, failedReason, prev }) => {
  // 'prev' is the state before failure (e.g., 'active', 'waiting')
  const job = await ordersQueue.getJob(jobId);
  if (job && job.attemptsMade >= (job.opts.attempts || 1)) {
    // All retries exhausted — move to DLQ
    await dlq.add('failed-order', {
      originalQueue: 'orders',
      jobId,
      data: job.data,
      failedReason,
      attemptsMade: job.attemptsMade,
      timestamp: Date.now(),
    });
    console.error(`Job ${jobId} moved to DLQ after ${job.attemptsMade} attempts`);
  }
});

// DLQ consumer for manual inspection
const dlqWorker = new Worker('dead-letter', async (job) => {
  const { originalQueue, jobId, failedReason, data } = job.data;
  
  // Log to monitoring system
  await logToDatadog('job.dlq', {
    queue: originalQueue,
    jobId,
    error: failedReason,
    data,
  });

  // Optionally alert the team
  if (job.data.data.amount > 10000) {
    await sendSlackAlert(`High-value order failed: ${jobId}`, data);
  }
}, { connection });

Stalled Job Detection

A job is "stalled" when a worker picks it up but crashes before completing. BullMQ's stalledInterval checks for stalled jobs and re-enqueues them:

const worker = new Worker('orders', processor, {
  connection,
  lockDuration: 30_000,       // Max time to hold a lock on a job
  stalledInterval: 15_000,    // Check for stalled jobs every 15s
  maxStalledCount: 3,         // Fail after 3 stalls (prevents infinite loops)
});

Graceful Shutdown

Always close workers and queues cleanly to avoid orphaned jobs:

const gracefulShutdown = async () => {
  console.log('Shutting down gracefully...');
  await emailWorker.close();   // Stop accepting new jobs, finish current
  await emailQueue.close();    // Disconnect from Redis
  await connection.quit();     // Close Redis connection
  process.exit(0);
};

process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

Advanced Queue Patterns

Advanced Queue Patterns

Job Flow (Chaining Jobs)

BullMQ's FlowProducer lets you chain jobs into pipelines where each step depends on the previous:

import { FlowProducer } from 'bullmq';

const flow = new FlowProducer({ connection });

await flow.addBulk([
  {
    name: 'process-order',
    queueName: 'orders',
    data: { orderId: 123 },
    children: [
      {
        name: 'charge-payment',
        queueName: 'payments',
        data: { orderId: 123, amount: 9999 },
        children: [
          {
            name: 'send-receipt',
            queueName: 'emails',
            data: { orderId: 123, type: 'receipt' },
          },
        ],
      },
      {
        name: 'update-inventory',
        queueName: 'inventory',
        data: { orderId: 123 },
      },
    ],
  },
]);

// The flow tree:
// process-order
//   ├── charge-payment
//   │     └── send-receipt
//   └── update-inventory

Concurrency Strategies

Different job types need different concurrency levels. Use separate workers with distinct settings:

// High concurrency for lightweight tasks
const emailWorker = new Worker('emails', emailProcessor, {
  connection,
  concurrency: 20,  // Handle 20 emails simultaneously
});

// Low concurrency for resource-heavy tasks
const reportWorker = new Worker('reports', reportProcessor, {
  connection,
  concurrency: 3,   // Only 3 reports at a time
});

// CPU-intensive with rate limiting
const exportWorker = new Worker('exports', exportProcessor, {
  connection,
  concurrency: 2,
  limiter: {
    max: 5,
    duration: 60_000, // 5 exports per minute
  },
});

Job Progress and Logging

For long-running jobs, report progress to give visibility:

const bulkEmailWorker = new Worker('bulk-emails', async (job) => {
  const recipients = job.data.recipients; // e.g., 10,000 emails
  const batchSize = 100;

  for (let i = 0; i < recipients.length; i += batchSize) {
    const batch = recipients.slice(i, i + batchSize);
    await Promise.all(batch.map(email => sendEmail(email)));

    // Update progress (0-100)
    const progress = Math.round((i + batch.length) / recipients.length * 100);
    await job.updateProgress(progress);

    // Log progress details
    job.log(`Sent batch ${i / batchSize + 1}: ${batch.length} emails`);
  }

  return { totalSent: recipients.length };
}, { connection });

// Monitor progress from outside
bulkEmailWorker.on('progress', (job, progress) => {
  console.log(`Job ${job.id} is ${progress}% complete`);
});

Priority Queues

Priority is set per-job. Lower numbers are processed first:

const taskQueue = new Queue('tasks', { connection });

// Regular tasks
await taskQueue.add('process-data', { dataset: 'user-events' }, { priority: 10 });
await taskQueue.add('process-data', { dataset: 'page-views' }, { priority: 10 });

// Urgent task — jumps ahead in the queue
await taskQueue.add('process-data', { dataset: 'security-alert' }, { priority: 1 });

// Background task — processed last
await taskQueue.add('process-data', { dataset: 'analytics-historical' }, { priority: 100 });

Sandbox Worker for CPU-Intensive Jobs

BullMQ supports sandboxed workers using worker-fork to isolate CPU-heavy processing from the main process:

// processor.js — runs in a child process
module.exports = async function(job) {
  const result = await heavyComputation(job.data);
  return result;
};

// main.js
const worker = new Worker('cpu-jobs', './processor.js', {
  connection,
  concurrency: 4,
  // Use child_process.fork for isolation
});

Monitoring & Production Best Practices

Production Monitoring

Queues are infrastructure. Without monitoring, failures are invisible until users complain.

Bull Board Dashboard

Bull Board provides a web UI to inspect queues, retry failed jobs, and monitor job status:

import express from 'express';
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, { readOnlyMode: false }),
    new BullMQAdapter(ordersQueue, { readOnlyMode: false }),
    new BullMQAdapter(reportsQueue, { readOnlyMode: false }),
  ],
  serverAdapter,
});

const app = express();
app.use('/admin/queues', serverAdapter.getRouter());
// Protect with authentication
app.use('/admin/queues', authenticateAdmin);
app.listen(3000);

Metrics Collection

Track queue health with Prometheus or similar metrics systems:

import { collectDefaultMetrics, Counter, Histogram } from 'prom-client';

const jobsProcessed = new Counter({
  name: 'queue_jobs_processed_total',
  help: 'Total number of jobs processed',
  labelNames: ['queue', 'status'],
});

const jobDuration = new Histogram({
  name: 'queue_job_duration_seconds',
  help: 'Job processing duration in seconds',
  labelNames: ['queue', 'job_name'],
  buckets: [0.1, 0.5, 1, 2, 5, 10, 30],
});

const worker = new Worker('orders', async (job) => {
  const end = jobDuration.startTimer({ queue: 'orders', job_name: job.name });
  try {
    const result = await processOrder(job.data);
    jobsProcessed.inc({ queue: 'orders', status: 'success' });
    end();
    return result;
  } catch (err) {
    jobsProcessed.inc({ queue: 'orders', status: 'failure' });
    end();
    throw err;
  }
}, { connection });

// Redis metrics for queue depth
async function getQueueMetrics() {
  const waiting = await emailQueue.getWaitingCount();
  const active = await emailQueue.getActiveCount();
  const completed = await emailQueue.getCompletedCount();
  const failed = await emailQueue.getFailedCount();
  const delayed = await emailQueue.getDelayedCount();

  return { waiting, active, completed, failed, delayed };
}

Production Checklist

Concern Implementation
Redis persistence Enable AOF (appendonly yes) and RDB snapshots
Redis high availability Use Redis Sentinel or Cluster
Worker isolation Sandbox CPU-intensive jobs
Graceful shutdown Handle SIGTERM, close workers cleanly
Dead letter queue Capture exhausted jobs for manual review
Monitoring Track queue depth, processing time, failure rate
Alerting Alert on queue depth > threshold or failure rate > 5%
Idempotency Design jobs to handle duplicate processing safely
Job TTL Set removeOnComplete and removeOnFail to prevent unbounded growth
Connection pooling Reuse Redis connections across workers

Idempotent Job Design

Jobs may be processed more than once (after a stall or crash). Design for idempotency:

const paymentWorker = new Worker('payments', async (job) => {
  const { orderId, paymentId } = job.data;

  // Check if already processed (idempotency key)
  const existing = await db.paymentResult.findOne({ paymentId });n  if (existing) {
    console.log(`Payment ${paymentId} already processed, skipping`);
    return existing;
  }

  const result = await chargePayment(job.data);
  await db.paymentResult.create({ paymentId, orderId, result });
  return result;
}, { connection });

Quiz

1. When using BullMQ, what happens to a job after all retry attempts are exhausted?

Question 1 options

2. What is the purpose of the `concurrency` option on a BullMQ Worker?

Question 2 options

3. Why is it important to design BullMQ jobs to be idempotent?

Question 3 options

Flashcards

Question

What are the main differences between Bull and BullMQ?

Answer

BullMQ is the modern successor to Bull with TypeScript support, Redis Cluster compatibility, better performance, and features like FlowProducer for job chaining, sandboxed workers, and improved rate limiting. Bull is deprecated and should not be used for new projects. BullMQ requires `maxRetriesPerRequest: null` in the Redis connection config.

Question

What is a Dead Letter Queue (DLQ) and when should you use one?

Answer

A Dead Letter Queue is a separate queue where jobs are moved after exhausting all retry attempts. It provides visibility into permanently failed jobs for manual inspection, debugging, and reprocessing. Use a DLQ when job failures could indicate data issues, third-party outages, or bugs that need investigation before automatic retry is safe.

Question

How does the `stalledInterval` and `maxStalledCount` configuration work in BullMQ?

Answer

`stalledInterval` (default 30s) is how often BullMQ checks for jobs that were picked up by a worker but never completed (e.g., worker crashed). `maxStalledCount` (default 1) controls how many times a job can be stalled before being moved to failed. A stalled job is re-enqueued for another worker to attempt, with the `stalled` count incremented.

Revision Notes

Key Takeaways

  • 1. Message queues decouple services for fault isolation, load leveling, and scalability. Bull/BullMQ is the standard for Node.js job processing backed by Redis.
  • 2. BullMQ supports regular, delayed, repeatable, rate-limited, and priority jobs. Use FlowProducer for multi-step job pipelines with dependencies.
  • 3. Configure retries with exponential backoff (`attempts: 4, backoff: { type: 'exponential', delay: 2000 }`). Always implement Dead Letter Queues for exhausted jobs.
  • 4. Design jobs for idempotency — stalls and crashes can cause duplicate processing. Use idempotency keys and check for existing results before side effects.
  • 5. Production queues need monitoring (queue depth, failure rate, processing time), graceful shutdown, and Redis persistence (AOF + RDB).

Interview Tips

  • Explain why you'd use a queue instead of synchronous processing — focus on fault isolation, load leveling, and temporal decoupling with concrete examples.
  • Walk through the job lifecycle: created → waiting → active → completed/failed. Mention stalled detection as a reliability mechanism.
  • Discuss retry strategies: why exponential backoff over fixed delay, and how to choose retry count based on error type (transient vs permanent).
  • Explain how to handle the scenario where a job needs to process exactly once — idempotency keys, database unique constraints, or message deduplication.
  • Know the difference between rate limiting at the queue level (BullMQ limiter) vs application level (Redis sliding window).

Cheat Sheet

Queue Setup: new Queue('name', { connection }) + new Worker('name', processor, { connection, concurrency }). Job Types: regular, delayed (delay: ms), repeatable (repeat: { cron }), rate-limited (limiter: { max, duration }), priority (priority: n). Retries: attempts: N, backoff: { type: 'exponential', delay: ms }. Events: completed, failed, stalled, progress. Patterns: Dead Letter Queue (DLQ), idempotent processing, FlowProducer for job chains. Production: Redis AOF persistence, Bull Board dashboard, prom-client metrics, graceful shutdown (worker.close()), removeOnComplete/removeOnFail for cleanup. Debugging: queue.getWaitingCount(), queue.getActiveCount(), queue.getFailedCount().