BullMQ vs Agenda: Node.js Job Queues for SaaS

Every SaaS hits the same wall: a route handler that does too much. Generating a PDF export, syncing a CRM, fanning out webhooks — do that work inside the open HTTP request and you block the event loop, drop connections, and watch your p99 latency climb. The fix is a background job queue, and in Node.js the BullMQ vs Agenda question is the one teams actually argue about.
Short answer: BullMQ for almost everyone, Agenda if you're MongoDB-only and schedule-heavy, Bee-Queue if you have one dead-simple high-volume task. The real divide isn't a benchmark — it's the data store. BullMQ keeps queue state in Redis and moves jobs with atomic operations; Agenda persists jobs as MongoDB documents and polls for them. That single architectural difference decides how each behaves when your traffic gets serious.

BullMQ: The Redis-Backed Default
BullMQ is the maintained, TypeScript-first successor to Bull, and it's our default for NestJS SaaS work. It leans on Redis primitives and atomic Lua scripts to manage queue state:
1[NestJS app] ──enqueue──> [atomic Lua script] ──> [Redis sorted set / stream] ──> [worker process]Because Redis runs commands on a single-threaded loop, BullMQ can bundle a multi-step operation — move a job from delayed to active and apply a rate limit and record the attempt — into one atomic script that executes in memory. That's what kills the race conditions you'd otherwise hand-roll, and it's why BullMQ handles high-velocity workloads (webhook fan-out, bulk exports, notification storms) without leaning on your primary database at all. If you're standing up the broader system, our background job queue architecture for NestJS covers the worker topology around it.
Agenda: MongoDB-Native, Schedule-First
If your stack is built entirely on MongoDB, Agenda is a reasonable, database-native choice that saves you from running a separate Redis instance. It serializes each job as a document in a Mongo collection, which makes it genuinely pleasant for long-range, calendar-driven jobs — "run this on the 1st of next month" persists naturally on disk.
The trade-off is the mechanism: Agenda polls the database with findAndModify to claim the next job. At low volume that's invisible. As volume climbs, that constant polling turns into disk I/O and row-lock contention on the same database serving your app — your queue and your product start fighting over the same connection pool. Agenda is a scheduler that happens to be a queue, and it's best kept to the workloads where that's true.

Bee-Queue: The Minimalist
Bee-Queue is also Redis-backed, but it strips out everything that isn't raw speed. No cron, no parent-child dependencies, no global rate limiting — just a fast, tiny queue for one simple job type done at volume, like resizing uploaded images. It's excellent at exactly that and the wrong tool the moment you need scheduling or job chains. Most teams who reach for Bee-Queue eventually migrate to BullMQ when feature needs grow; if you already know you'll need those features, start on BullMQ.
BullMQ vs Agenda vs Bee-Queue: The Feature Grid
| Capability | BullMQ | Agenda | Bee-Queue |
|---|---|---|---|
| Data store | Redis (streams/hashes) | MongoDB collection | Redis (lists) |
| Job claiming | Atomic Lua, in-memory | DB interval polling | Basic Redis ops |
| Relative throughput | High | Low–moderate (polling-bound) | High (single-purpose) |
| Parent-child jobs | Yes | No | No |
| Global rate limiting | Yes | Concurrency caps only | No |
| Long-range scheduling | Good (Redis RAM) | Excellent (disk-persistent) | Limited |
The throughput column is relative on purpose — I'm not going to quote you a "10,000 jobs/sec" number from a benchmark that used a machine and payload nothing like yours. The architectural truth is what carries over: in-memory atomic ops scale further than database polling, full stop.
Production: BullMQ in NestJS

BullMQ is our recommendation for multi-tenant products, and the official @nestjs/bullmq package keeps the wiring clean. A processor with concurrency and rate limiting:
1// src/processors/crm-sync.processor.ts
2import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
3import { Job } from 'bullmq';
4import { Logger } from '@nestjs/common';
5
6@Processor('crm-sync-queue', {
7 concurrency: 5, // 5 jobs in parallel per worker
8 limiter: { max: 100, duration: 10_000 }, // at most 100 jobs / 10s
9})
10export class CrmSyncProcessor extends WorkerHost {
11 private readonly logger = new Logger(CrmSyncProcessor.name);
12
13 async process(job: Job<{ tenantId: string; payload: unknown }>): Promise<{ ok: true }> {
14 const { tenantId } = job.data;
15 this.logger.log(`Syncing CRM for tenant ${tenantId}`);
16 // Any thrown error here triggers BullMQ's retry/backoff automatically.
17 return { ok: true };
18 }
19
20 @OnWorkerEvent('failed')
21 onFailed(job: Job, err: Error) {
22 this.logger.error(`Job ${job.id} failed: ${err.message}`);
23 }
24}The detail people skip is the retry policy. Configure exponential backoff with jitter on the queue, because the third-party API that "never goes down" is the one that hangs on a Tuesday — and a queue without backoff answers an outage by stampeding the recovering service. This matters most for outgoing webhook delivery, where the receiver is someone else's flaky endpoint.
Which One to Pick
- BullMQ — scaling multi-tenant SaaS, high-velocity processing, anything needing retries, rate limits, or job chains. The default. (You'll likely already have Redis in your stack for caching, so the marginal infra cost is near zero.)
- Agenda — MongoDB-only infrastructure, sub-few-hundred-jobs-per-second volume, and a feature set dominated by future-dated scheduling.
- Bee-Queue — one simple, high-volume task and a hard requirement to keep the footprint minimal.
Pick the queue that matches your data store and your failure modes, not the one with the loudest throughput claim. A queue's real job is to keep doing work quietly while a third-party API is having its worst day — and the one you'll thank yourself for at 3am is the one whose retries you actually configured.
Frequently Asked Questions
For most SaaS workloads, BullMQ. It's Redis-backed and uses atomic Lua scripts to move jobs between states in memory, which avoids the database polling that makes Agenda struggle under load. Choose Agenda only if your stack is already MongoDB-only and your jobs are low-volume, calendar-driven schedules where avoiding a separate Redis instance is worth the throughput trade-off.
Queues like Agenda poll the database (findAndModify) to find and lock the next job. As volume grows, that constant polling generates disk I/O and row-lock contention on your primary database — the same database serving your app. Redis-backed queues like BullMQ hold and mutate queue state in memory with atomic operations, so they sidestep disk I/O and don't compete with your app's database connections.
Use Bee-Queue when you have one simple, high-volume job type — image resizing, thumbnail generation — and you want minimal overhead. It's Redis-backed and fast but deliberately drops cron scheduling, parent-child job dependencies, and advanced rate limiting. The moment you need any of those, you've outgrown Bee-Queue and should be on BullMQ.
A backoff strategy controls how long a worker waits before retrying a failed job. Without it, a third-party API outage turns your queue into a retry storm that hammers the recovering service and your own workers. Exponential backoff with jitter spaces retries out and adds randomness so a thousand jobs don't all retry on the same tick — giving the downstream system room to recover.
Avoid Kue (deprecated, known memory issues) and the original Bull package — it's superseded by BullMQ, which is the maintained, TypeScript-first successor from the same authors. If you're on legacy Bull or Kue, treat migrating to BullMQ as the default, not a research project.
