Message Queues in Node.js — BullMQ and RabbitMQ Guide 2026
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 ioredisimport { 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/amqplibimport 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
| Criteria | BullMQ | RabbitMQ |
|---|---|---|
| Language | Node.js only | Any language |
| Protocol | Redis commands | AMQP |
| Job scheduling | Built-in cron | Needs plugins |
| Dashboard | Bull Board | Management UI |
| Complexity | Low | Higher |
| Best for | Background jobs | Event-driven microservices |
Common Mistakes
- Using the legacy
bullpackage — migrate tobullmq, which has TypeScript support and active maintenance - Not setting
maxRetriesPerRequest: nullon 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: truein RabbitMQ publishes — messages are lost on broker restart
Best Practices
- Set
concurrencyin 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, andjob.attemptsMadein 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
attemptsandbackoffin BullMQ to automatically retry transient failures - Use
UnrecoverableErrorin 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
Related reading
BullMQ in Production — Priority Queues, Rate Limiting, and Dead Letter Handling9 min readPrisma ORM Guide 2026 — Type-Safe Database Access with PostgreSQL5 min readRedis Caching Guide 2026 — Improve API Performance 10x5 min readAPI-First Development in 2026 — Design, Mock, Validate, Then Build6 min readbetter-auth — The Open-Source Auth Library That Replaces NextAuth6 min readCache Invalidation Hell — The Second Hardest Problem in Computer Science6 min read