BullMQ has over 4.4 million weekly npm downloads — a number that reflects how many Node.js developers reach for a Redis-backed queue before considering a standalone message broker like RabbitMQ. Both are excellent tools, and I've used both in production. For Commsult Indonesia's ERP, I use BullMQ to handle email notifications and invoice generation jobs — it's the right fit for that workload. Understanding when each tool excels is the difference between a maintainable architecture and an over-engineered one.
BullMQ is a Node.js queue library built on Redis. It provides job queues with priorities, delayed jobs, repeatable jobs (cron-style), concurrency control, rate limiting, and a dashboard (Bull Board) for monitoring. Because it runs on Redis, which you likely already have in your stack for caching, BullMQ adds no new infrastructure dependency. It's the right choice when your queue is consumed exclusively by Node.js workers in a single organization's system.
In our ERP email automation, an invoice.paid event triggers a BullMQ job that generates the PDF receipt and sends the confirmation email. We use three queue features: job priority (time-sensitive notifications get higher priority than monthly reports), delayed jobs (schedule follow-up emails 7 days after invoice creation), and job retries with exponential backoff (if the email provider is temporarily unavailable, retry 3 times with increasing delays before moving to DLQ). Bull Board gives the finance team visibility into pending and failed jobs without needing database access.
BullMQ vs RabbitMQ — When to Use Which
BullMQ (Redis-backed Job Queue)
─────────────────────────────────────────────────────────
NestJS Service ──► Redis ──► BullMQ Worker (NestJS)
│
Job States:
waiting → active → completed
└─► failed → DLQ
Use for: Background jobs, scheduled tasks, retry logic
Ecosystem: Node.js only | Dashboard: Bull Board UI
─────────────────────────────────────────────────────────
RabbitMQ (AMQP Message Broker)
─────────────────────────────────────────────────────────
┌─── Direct Exchange ──► Queue A (NestJS)
Producer ──► Exchange ┤
(Any lang) ├─── Topic Exchange ──► Queue B (Python)
└─── Fanout Exchange ──► Queue C (Java)
Use for: Cross-language, complex routing, shared infra
Features: Dead Letter Exchange, Per-queue TTL, PriorityFrom building the email automation in our ERP: set a maxAttempts limit on every job — never leave it at the default of retrying indefinitely. A malformed job payload that always throws will otherwise pile up retries forever, consuming Redis memory and CPU. Also name your queues descriptively ('erp:invoice:email', 'erp:report:generation') rather than 'default' — when you look at Redis keys six months later, you'll understand immediately what each queue is for.
RabbitMQ is a standalone message broker implementing AMQP (Advanced Message Queuing Protocol). It supports exchanges, queues, bindings, routing keys, and multiple exchange types (direct, fanout, topic, headers). Unlike BullMQ, RabbitMQ is language-agnostic — producers and consumers can be written in any language. It supports message acknowledgments, dead-letter exchanges, per-queue TTL, and priority queues at the protocol level. RabbitMQ is the right choice when you need cross-language message passing or when your message broker is a shared infrastructure component used by multiple teams and services.
// BullMQ setup with NestJS
// app.module.ts
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.forRoot({ connection: { host: 'localhost', port: 6379 } }),
BullModule.registerQueue(
{ name: 'erp:invoice:email' },
{ name: 'erp:report:generation' },
),
],
})
export class AppModule {}
// invoice-email.processor.ts
@Processor('erp:invoice:email')
export class InvoiceEmailProcessor {
@Process()
async handleInvoiceEmail(job: Job<InvoiceEmailPayload>) {
await this.emailService.sendInvoice(job.data);
}
}
// invoice.service.ts — Adding jobs to the queue
@Injectable()
export class InvoiceService {
constructor(
@InjectQueue('erp:invoice:email') private emailQueue: Queue,
) {}
async markPaid(invoiceId: string) {
await this.repo.update(invoiceId, { status: 'paid' });
await this.emailQueue.add(
'send-receipt',
{ invoiceId },
{
attempts: 3, // max retries
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100, // keep last 100 completed
removeOnFail: 200, // keep last 200 failed for inspection
}
);
}
}RabbitMQ's routing model is more flexible than BullMQ's simple queues. A Direct exchange routes messages to queues based on exact routing key match — useful for command-style messaging where each message has one handler. A Topic exchange routes based on routing key patterns ('order.#' matches 'order.created', 'order.paid', 'order.cancelled') — useful for pub/sub with multiple subscribers. A Fanout exchange broadcasts to all bound queues — useful for event fan-out when multiple services need every event. This routing flexibility makes RabbitMQ better suited for complex event distribution topologies.
A common mistake with RabbitMQ is forgetting to acknowledge messages. By default, RabbitMQ marks a message as delivered as soon as it's sent to the consumer — if the consumer crashes before processing it, the message is lost. Always use manual acknowledgment (ack after successful processing) and configure publisher confirms on the producer side. Also, RabbitMQ queues are finite — if your consumer is slower than your producer and you haven't set a max queue length, you'll exhaust memory. Always configure x-max-length and a dead-letter exchange.
NestJS supports both out of the box. For BullMQ, use @nestjs/bullmq — register the queue with BullModule.registerQueue(), decorate processors with @Processor and @Process. For RabbitMQ, use @nestjs/microservices with the AMQP transport, or the community @golevelup/nestjs-rabbitmq package which provides cleaner decorator-based consumer registration. Both integrations give you DI-compatible consumers that work naturally in the NestJS module system.
Choose BullMQ when: your stack is Node.js-only, you already have Redis, you need scheduled/delayed jobs, you want a UI dashboard included, and your queue is internal to one service group. Choose RabbitMQ when: multiple languages produce or consume messages, you need complex routing topology (topic/fanout exchanges), your messaging infrastructure is shared across teams, or you need AMQP-level protocol guarantees. For an ERP or SaaS application built purely in NestJS, BullMQ is the pragmatic default.
Positioning all three: BullMQ is a job queue — good for background tasks, scheduled work, and retry-with-backoff patterns within a Node.js ecosystem. RabbitMQ is a message broker — good for polyglot event-driven systems with routing requirements. Kafka is an event streaming platform — good for high-throughput event replay, event sourcing, and systems where multiple services need durable event history. Complexity and operational overhead increase in that order. Start with BullMQ, graduate to RabbitMQ when you need cross-language or complex routing, and reach for Kafka only when you need true event streaming semantics.