What NestJS BullMQ is and how a job flows through Redis
BullMQ is a Node.js job queue built on Redis. Producers add jobs to a named queue, and workers pull jobs and run your processing function. The NestJS integration (@nestjs/bullmq) wraps this in modules and decorators. You register queues through BullModule, inject them into services, and declare workers as classes marked with @Processor. Connections and lifecycle then live in the NestJS dependency injection container instead of ad hoc setup code.
Teams use it to move slow or failure-prone work out of the request path. Typical examples are sending email, calling third-party APIs and generating files. A Redis queue absorbs traffic spikes so downstream systems see a steady rate instead of bursts. It fits backend and platform engineers running Node.js services who already operate Redis and want durable background work without adding a separate broker.
The job lifecycle
A new job enters waiting, or delayed if it was scheduled for later. A worker claims it and moves it to active, holding a lock that it renews while processing. From there:
- Success moves the job to completed.
- A thrown error with attempts remaining sends the job to delayed for its backoff period, then back to waiting. This is where retries act.
- A thrown error with attempts exhausted moves the job to failed.
Two other controls shape this flow. The limiter caps how many jobs a worker starts per time window, so excess jobs stay in waiting. Stall detection catches jobs whose lock expired, usually because the process crashed or the event loop was blocked. It returns those jobs to waiting, or fails them after repeated stalls.
Each control on its own seems simple, but they interact. Retries add load, concurrency multiplies it, and the limiter decides how much of that load reaches downstream systems. The rest of this article treats the three as one combined policy for throughput and failure handling.
+---------------------------+
| Producer: queue.add(...) |
+--+-------------------+------+
| now | delayed
v v
+---------+ +---------+
| waiting |<--------| delayed |<-----------------------+
+--+---+--+ delay +---------+ |
^ | elapsed |
| | [limiter] caps jobs started per window |
| | over the cap, jobs stay in waiting |
| v |
| +--------------------------------------+ |
| | active: Worker (@Processor) | |
| | up to N jobs at once (concurrency) | |
| | lock held, renewed while running | |
| +--+-------------+-----------------+---+ |
| | | | |
| | lock | success | thrown error |
| | expired v v |
+----+ +-----------+ +--------------+ |
[stall check] | completed | | attempts | |
repeated: +-----------+ | remaining? | |
stalls fail it +--+--------+--+ |
no | | yes |
v +---------+
+--------+ retry after backoff
| failed |
+--------+
A BullMQ job's path through its Redis-backed states, showing where the limiter holds jobs in waiting, where concurrency bounds active work, where retries route failed attempts through delayed with backoff, and where stall detection returns jobs whose lock expired.
Wiring BullMQ into a NestJS module
The integration lives in the @nestjs/bullmq package, which wraps BullMQ's Queue and Worker classes in Nest's dependency injection. Wiring it takes four steps.
1. Register the connection once
Call BullModule.forRoot() in the root module with the Redis connection options. Use forRootAsync() if host and credentials come from ConfigService. Every queue and worker reuses this configuration, so connection settings are defined in one place and cannot drift between producers and consumers.
2. Register each queue by name
In the feature module, call BullModule.registerQueue({ name: 'email' }). The name is the contract between producer and consumer. A typo creates a second, empty queue that nothing processes, so keep names in a shared constant.
3. Produce jobs from a service
Inject the queue with @InjectQueue('email'), typed as BullMQ's Queue. Then call queue.add(jobName, payload, options). Per-job options such as attempts and backoff belong here or in the queue's defaultJobOptions. The payload is serialized to Redis, so pass IDs and small values, not entity instances.
4. Consume with a processor
Decorate a class with @Processor('email'), extend WorkerHost, and implement async process(job). Nest creates a BullMQ Worker for it. Whatever process returns becomes the job's return value. A thrown error marks the attempt as failed.
Shutting down cleanly
Call app.enableShutdownHooks() in main.ts. Without it, Nest ignores SIGTERM. The process then dies with jobs mid-flight, and those jobs are only recovered later through stall detection. With shutdown hooks enabled, the worker closes: it stops taking new jobs and waits for the active ones to finish.
That wait has to fit inside your orchestrator's termination grace period, such as terminationGracePeriodSeconds in Kubernetes. Set the grace period above the duration of your slowest job, plus margin. Otherwise the SIGKILL arrives first and you are back to stalled jobs.
import { Module, Injectable } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { BullModule, InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';
import { Queue, Job } from 'bullmq';
// One shared constant so producer and consumer cannot drift apart
export const EMAIL_QUEUE = 'email';
@Injectable()
export class EmailService {
constructor(@InjectQueue(EMAIL_QUEUE) private readonly queue: Queue) {}
// Pass IDs, not entities; the payload is serialized to Redis
async sendWelcome(userId: string) {
await this.queue.add('welcome', { userId }, {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
});
}
}
@Processor(EMAIL_QUEUE)
export class EmailProcessor extends WorkerHost {
// Return value becomes job.returnvalue; a thrown error fails this attempt
async process(job: Job<{ userId: string }>): Promise<{ sent: boolean }> {
// ...load the user by job.data.userId and send the email here
return { sent: true };
}
}
@Module({
imports: [
// Connection defined once; every queue and worker reuses it
BullModule.forRoot({ connection: { host: 'redis.internal', port: 6379 } }),
BullModule.registerQueue({ name: EMAIL_QUEUE }),
],
providers: [EmailService, EmailProcessor],
})
export class AppModule {}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Without this, SIGTERM kills the process and in-flight jobs wait for stall detection
app.enableShutdownHooks();
await app.listen(3000);
}
bootstrap();
A single-file sketch of the four wiring steps: a shared Redis connection, a named queue, a service that adds jobs with retry options, a WorkerHost processor, and shutdown hooks so the worker can drain active jobs on SIGTERM.
Designing bullmq retries around the failure cause
Every retry is a bet that the next attempt will behave differently from the last one. Whether that bet pays depends on why the job failed, so tune attempts and backoff per queue or per job type instead of setting one global default.
BullMQ supports two built-in backoff types. With fixed, every retry waits the same delay. With exponential, the wait roughly doubles on each attempt, starting from delay. You can also register a custom backoffStrategy on the worker. This is useful when an upstream service tells you exactly how long to wait.
| Failure cause | Retry? | Backoff | Reasoning |
|---|---|---|---|
| Network timeout, transient 5xx | Yes, 3–5 attempts | Exponential | Short blips clear quickly, and longer outages get progressively less pressure |
| Upstream 429 | Yes | Custom, based on Retry-After |
The upstream has told you when capacity returns |
| Validation error, malformed payload | No | None | The same input will fail on every attempt |
For bad input, throw UnrecoverableError from bullmq. The job moves straight to failed and skips its remaining attempts, so it does not occupy retry slots.
There is no built-in dead-letter queue. Instead, handle the worker's failed event and compare job.attemptsMade with job.opts.attempts. When they match, the job has exhausted its retries. Copy its data and failedReason to a separate queue for inspection or replay, or send an alert.
Backoff length is a trade-off in both directions:
- Short backoff sends repeated waves of traffic at a struggling dependency, which can make the outage worse.
- Long backoff delays recovery after the dependency is back. It also grows the delayed sorted set in Redis, which costs memory and makes the backlog harder to reason about.
import { InjectQueue, OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job, Queue, UnrecoverableError } from 'bullmq';
class RateLimitedError extends Error {
constructor(public readonly retryAfterMs: number) { super('Upstream returned 429'); }
}
// Producer side: queue.add('send', data, { attempts: 5, backoff: { type: 'custom' } })
@Processor('webhooks', {
settings: {
// Runs for jobs whose backoff type is 'custom'; the delay depends on why the job failed
backoffStrategy: (attemptsMade: number, _type?: string, err?: Error) => {
if (err instanceof RateLimitedError) return err.retryAfterMs; // upstream told us when to come back
return Math.min(1_000 * 2 ** (attemptsMade - 1), 60_000); // capped exponential for timeouts/5xx
},
},
})
export class WebhookProcessor extends WorkerHost {
constructor(@InjectQueue('webhooks-dead-letter') private readonly deadLetter: Queue) { super(); }
async process(job: Job<{ url: string; body: unknown }>) {
// Bad input fails the same way every time, so skip the remaining attempts
if (!job.data.url?.startsWith('https://')) throw new UnrecoverableError('Invalid url');
const res = await fetch(job.data.url, {
method: 'POST', body: JSON.stringify(job.data.body), signal: AbortSignal.timeout(10_000),
});
if (res.status === 429) {
const seconds = Number(res.headers.get('retry-after'));
throw new RateLimitedError((Number.isFinite(seconds) && seconds > 0 ? seconds : 30) * 1_000);
}
if (!res.ok) throw new Error(`Upstream responded ${res.status}`); // retried with backoff
}
@OnWorkerEvent('failed')
async onFailed(job: Job | undefined, err: Error) {
if (!job) return;
const exhausted = job.attemptsMade >= (job.opts.attempts ?? 1) || err instanceof UnrecoverableError;
if (!exhausted) return; // a retry is still scheduled
// Dead-letter style: keep payload and reason for inspection or manual replay
await this.deadLetter.add('dead', { original: job.data, jobId: job.id, reason: job.failedReason });
}
}
A NestJS BullMQ processor that uses a custom backoff strategy to choose the retry delay by failure cause, honoring Retry-After for 429s and applying capped exponential backoff otherwise, fails bad input immediately with UnrecoverableError, and copies exhausted jobs to a dead-letter queue.
Job concurrency, rate limiting and downstream capacity
The concurrency option sets how many jobs a single Worker instance processes at once. Your real ceiling is replicas × workers per process × concurrency. Scaling a deployment from 2 to 6 pods triples the load on everything downstream, even though no queue setting changed. Concurrency only helps with I/O-bound work. A CPU-bound handler, such as image resizing or a large JSON transform, blocks the Node.js event loop, so a concurrency of 20 still runs one job at a time. Higher values in that case only add lock-renewal pressure.
To bound throughput, set the Worker limiter with max jobs per duration milliseconds. BullMQ enforces it through Redis, so the cap applies to the whole queue across all workers and replicas, not to each process. When an upstream API returns 429, call the worker's rateLimit method with the Retry-After delay and throw the rate-limit error. The job goes back to waiting without consuming a retry attempt.
Illustrative estimate (assumed inputs): the partner API allows 50 requests/s, each job makes one call taking about 200 ms, there are 4 replicas, and each replica has a DB pool of 10 connections.
- Sustaining 50 jobs/s needs about 50 × 0.2 = 10 jobs in flight, which is roughly 3 per replica.
- Set
limitertomax: 50, duration: 1000. - Set concurrency to 4 per replica. That gives some headroom while staying below the pool size, which leaves connections free for HTTP traffic.
The limiter applies to every job on a queue. If an email job type and a webhook job type face different upstream limits, putting them in one queue lets the stricter limit throttle both. Give each a separate queue with its own limiter and concurrency.
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job, Worker } from 'bullmq';
// Sized from the illustrative estimate: 4 replicas x concurrency 4 = 16 slots,
// which covers ~10 in-flight jobs at 50 req/s x 200 ms, and stays below each
// replica's DB pool of 10.
@Processor('partner-sync', {
concurrency: 4, // per Worker instance, i.e. per replica here
// Enforced through Redis: at most 50 jobs per second across ALL replicas
limiter: { max: 50, duration: 1000 },
})
export class PartnerSyncProcessor extends WorkerHost {
async process(job: Job<{ accountId: string }>): Promise<void> {
const res = await fetch(
`
{ method: 'POST' },
);
if (res.status === 429) {
// Honor Retry-After (seconds); fall back to 1s if absent or invalid
const seconds = Number(res.headers.get('retry-after'));
const delayMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 1000;
// Pause the whole queue for delayMs, then return this job to waiting
// without consuming one of its retry attempts
await this.worker.rateLimit(delayMs);
throw Worker.RateLimitError();
}
if (!res.ok) {
// Other failures go through the normal attempts/backoff policy
throw new Error(`Partner API responded ${res.status}`);
}
}
}
// Different upstream limits get separate queues with separate processors, e.g.
// @Processor('email', { concurrency: 10, limiter: { max: 10, duration: 1000 } })
A NestJS BullMQ processor that sets per-replica concurrency and a Redis-enforced queue-wide limiter, and applies a manual rate limit from the Retry-After header when the partner API returns 429.
Idempotency, stalled jobs and sandboxed processors
BullMQ delivers jobs at least once, not exactly once. A worker holds a lock on each active job and renews it periodically. If the process crashes, or a synchronous task blocks the event loop long enough that renewal misses the lock duration, the lock expires. The stalled-job checker then moves the job back to waiting, and another worker picks it up. The first worker may still be running. After maxStalledCount stalls the job fails instead of retrying, but within that budget two executions of the same job are normal behavior.
Design for that at two layers. At enqueue time, derive the job ID from the business entity, such as invoice:{id}:send. BullMQ ignores an add with an existing ID, but only while that job is still stored, so removeOnComplete settings shorten the deduplication window. At execution time, make side effects idempotent. Pass an idempotency key to the payment provider, upsert rows on a unique constraint, and record "email sent" in the same transaction that checks it.
A longer lock duration reduces false stalls on slow jobs. The cost is that a crashed worker's jobs wait longer before anyone recovers them. Sandboxed processors run each job in a separate child process, so CPU-heavy work cannot starve lock renewal. In exchange you pay process overhead and lose NestJS dependency injection, because the file must build its own clients or a standalone application context.
| Approach | Protects against | Cost |
|---|---|---|
| Deterministic job ID | Duplicate enqueues | Only effective while the job is retained |
| Idempotency key / upsert | Double execution after a stall | Schema or provider support |
| Longer lock duration | False stalls on slow I/O | Slower recovery after crashes |
| Sandboxed processor | Event-loop blocking | Process overhead, no DI |
Keeping Redis healthy in production
Every finished job stays in Redis unless you tell BullMQ otherwise. Set removeOnComplete and removeOnFail per job or as queue defaults, using a count, an age, or both. Completed jobs are rarely worth keeping beyond a short debugging window. Failed jobs are your evidence, so retain them longer, sized to how quickly someone actually investigates. If you need an audit trail, write it to a database from the worker. Redis is a poor long-term archive, and unbounded retention eventually becomes a memory incident.
Give Redis an explicit maxmemory and set maxmemory-policy to noeviction. Under any eviction policy, Redis may silently delete a job hash, a lock or part of a queue's internal structures. That leaves orphaned IDs and lost work. With noeviction, writes fail loudly when memory is full. A failed add call surfaces in your producer, where you can handle it.
Persistence decides what survives a restart. With no persistence, a restart empties every queue. RDB snapshots lose everything written since the last snapshot. AOF with appendfsync everysec narrows that loss to roughly a second of writes. For job queues, AOF is usually the right default.
Metrics and alerts
Poll waiting, active, failed and delayed counts, for example with getJobCounts, and alert relative to the policy you set earlier:
- Waiting keeps growing while active stays at your concurrency ceiling. Throughput is below the arrival rate.
- Failed rises faster than your retry budget predicts. Retries are exhausting, so this is not a transient fault.
- Delayed keeps climbing. Backoff or rate limiting is deferring more work than it drains.
- Redis memory passes about 80% of
maxmemory. Recheck retention before writes start failing.
When to avoid BullMQ, alternatives, and impact on cost and engineering time
BullMQ fits a Node.js service that already runs Redis, handles moderate throughput, and needs delayed jobs, retries, and per-queue rate limits. Avoid it when you need strict durability or ordering, because Redis persistence settings can lose recently acknowledged writes and parallel workers reorder jobs. It is also a poor fit for very high-volume event streams that consumers must replay, for consumers written in other languages, and for teams that will not treat Redis as a critical, monitored dependency.
| Option | Durability and ordering | Replay | Cross-language | Operational cost |
|---|---|---|---|---|
| In-process scheduling | Lost on restart | No | No | Lowest |
| Database-backed queue | Transactional with your data | Limited | Via SQL | Reuses existing DB |
| BullMQ on Redis | Depends on Redis persistence | No | Weak | Redis HA and memory tuning |
| Message broker | Durable with acknowledgements | Usually no | Strong | A new cluster to run |
| Log-based streaming | Durable, ordered per partition | Yes | Strong | Highest |
| Managed cloud queue | Provider-managed | Varies | Strong | Low ops, vendor coupling |
A database-backed queue is often the better choice when a job must be enqueued atomically with a business write. BullMQ cannot join your database transaction, so you would need an outbox table.
BullMQ still has development costs. You must operate Redis with persistence, eviction disabled, and failover. Every handler needs idempotency work, and you need dashboards for the queue states described above.
In return, you do not write retry scheduling, exponential backoff, rate limiting, delayed execution, or stalled-job recovery yourself. Hand-rolled versions of these tend to fail in edge cases such as a worker crashing while it holds a job lock. BullMQ has already worked through those cases.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nestjs bullmq, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.