Message Queues in Node.js — BullMQ and RabbitMQ Guide 2026

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why Message Queues Matter

Synchronous APIs block. When a user registers, you should not make them wait for a welcome email to send before responding. Message queues decouple the request from the work, letting you acknowledge immediately and process asynchronously.

Use queues for: email/SMS sending, image processing, report generation, webhook delivery, scheduled tasks, and any work that can fail and needs retry logic.

BullMQ — Redis-Backed Queues for Node.js

BullMQ is the modern TypeScript rewrite of Bull, built on Redis Streams. It is the standard choice for Node.js-only systems.

npm install bullmq
npm install ioredis
import { Queue, Worker, Job } from 'bullmq';
import { Redis } from 'ioredis';
 
const connection = new Redis({ maxRetriesPerRequest: null });
 
// --- Producer (add jobs) ---
const emailQueue = new Queue('emails', { connection });
 
await emailQueue.add(
  'welcome-email',
  { to: 'user@example.com', name: 'Alice' },
  {
    attempts: 3,
    backoff: { type: 'exponential', delay: 2000 },
    removeOnComplete: { count: 100 },
    removeOnFail: { count: 50 },
  }
);
 
// --- Scheduled / delayed job ---
await emailQueue.add(
  'reminder',
  { to: 'user@example.com' },
  { delay: 24 * 60 * 60 * 1000 }  // 24 hours
);
 
// --- Repeating job (cron) ---
await emailQueue.add(
  'weekly-digest',
  {},
  { repeat: { pattern: '0 9 * * 1' } }  // every Monday 9 AM
);

BullMQ Workers

import { Worker, Job, UnrecoverableError } from 'bullmq';
 
interface EmailJob {
  to: string;
  name: string;
}
 
const worker = new Worker<EmailJob>(
  'emails',
  async (job: Job<EmailJob>) => {
    console.log(`Processing job ${job.id}: send to ${job.data.to}`);
 
    if (!job.data.to.includes('@')) {
      // Throw UnrecoverableError to skip retries
      throw new UnrecoverableError('Invalid email address');
    }
 
    await sendEmail(job.data.to, job.data.name);
    return { sent: true };
  },
  {
    connection,
    concurrency: 5,   // process 5 jobs in parallel
    limiter: { max: 100, duration: 60_000 },  // 100 jobs/min
  }
);
 
worker.on('completed', (job, result) => {
  console.log(`Job ${job.id} done`, result);
});
 
worker.on('failed', (job, err) => {
  console.error(`Job ${job?.id} failed: ${err.message}`);
});

BullMQ Job Flows (Parent/Child)

import { FlowProducer } from 'bullmq';
 
const flow = new FlowProducer({ connection });
 
// Child jobs must complete before parent runs
await flow.add({
  name: 'process-order',
  queueName: 'orders',
  data: { orderId: '123' },
  children: [
    { name: 'charge-card',   queueName: 'payments', data: { orderId: '123' } },
    { name: 'reserve-stock', queueName: 'inventory', data: { orderId: '123' } },
  ],
});

RabbitMQ — AMQP for Polyglot Systems

Choose RabbitMQ when workers are written in multiple languages, or you need exchange/routing patterns.

npm install amqplib
npm install --save-dev @types/amqplib
import amqp, { Channel, Connection } from 'amqplib';
 
let connection: Connection;
let channel: Channel;
 
async function connect() {
  connection = await amqp.connect(process.env.RABBITMQ_URL ?? 'amqp://localhost');
  channel = await connection.createChannel();
  channel.prefetch(10);  // process 10 messages at a time
}
 
// --- Publisher ---
async function publish(exchange: string, routingKey: string, data: unknown) {
  await channel.assertExchange(exchange, 'topic', { durable: true });
  channel.publish(
    exchange,
    routingKey,
    Buffer.from(JSON.stringify(data)),
    { persistent: true, contentType: 'application/json' }
  );
}
 
// Emit user.created event
await publish('app.events', 'user.created', { userId: 42, email: 'a@b.com' });
 
// --- Consumer ---
async function consume(exchange: string, queue: string, pattern: string) {
  await channel.assertExchange(exchange, 'topic', { durable: true });
  const q = await channel.assertQueue(queue, { durable: true });
  await channel.bindQueue(q.queue, exchange, pattern);
 
  channel.consume(q.queue, async (msg) => {
    if (!msg) return;
    try {
      const data = JSON.parse(msg.content.toString());
      await handleEvent(data);
      channel.ack(msg);
    } catch (err) {
      channel.nack(msg, false, false);  // dead-letter on failure
    }
  });
}
 
// Listen for all user events
await consume('app.events', 'email-service', 'user.*');

Dead Letter Queues

// Configure queue with dead-letter routing
await channel.assertQueue('orders', {
  durable: true,
  arguments: {
    'x-dead-letter-exchange': 'dlx',
    'x-message-ttl': 30_000,  // 30s timeout before dead-lettering
  },
});
 
await channel.assertExchange('dlx', 'fanout', { durable: true });
const dlq = await channel.assertQueue('orders.failed', { durable: true });
await channel.bindQueue(dlq.queue, 'dlx', '');

BullMQ vs RabbitMQ — When to Use Each

CriteriaBullMQRabbitMQ
LanguageNode.js onlyAny language
ProtocolRedis commandsAMQP
Job schedulingBuilt-in cronNeeds plugins
DashboardBull BoardManagement UI
ComplexityLowHigher
Best forBackground jobsEvent-driven microservices

Common Mistakes

  • Using the legacy bull package — migrate to bullmq, which has TypeScript support and active maintenance
  • Not setting maxRetriesPerRequest: null on the Redis connection for BullMQ — causes timeout errors
  • Acknowledging messages before processing in RabbitMQ — message is lost if the worker crashes
  • Using a single queue for all job types — separate queues allow independent scaling and monitoring
  • Omitting persistent: true in RabbitMQ publishes — messages are lost on broker restart

Best Practices

  • Set concurrency in BullMQ workers based on CPU/IO balance — CPU tasks use 1-2, IO tasks use 10-50
  • Add job IDs for deduplication: { jobId: 'unique-id' } prevents duplicate jobs in BullMQ
  • Use Bull Board or BullMQ Pro dashboard to monitor queue depths and failed jobs
  • Implement graceful shutdown — call worker.close() and drain queues before killing the process
  • Log job.id, job.name, and job.attemptsMade in every worker for traceability

Key Takeaways

  • Message queues decouple request handling from background work, improving API response times
  • BullMQ is the standard for Node.js job queues — TypeScript-native, Redis-backed, feature-rich
  • RabbitMQ is better for polyglot architectures and complex publish/subscribe routing patterns
  • Always configure attempts and backoff in BullMQ to automatically retry transient failures
  • Use UnrecoverableError in BullMQ to skip retries for invalid or permanently failing jobs
  • Dead-letter queues capture messages that cannot be processed — inspect them to debug failures
  • Separate queues for separate job types enables independent scaling, priority, and rate limiting
  • Shut down workers gracefully to avoid orphaned jobs — always drain before process exit

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading