Background jobs in NestJS with BullMQ: what I actually use in production
How I structure background job processing in NestJS projects — queues, workers, retries, and the patterns that hold up under real load.
Every production system eventually needs to do work outside the request-response cycle. Send an email after signup. Process an uploaded file. Sync data with a third-party API. Retry a failed webhook delivery.
In every NestJS project I build, BullMQ is my default answer to this problem.
Here's how I actually set it up — not a tutorial that ends at "it works locally", but the patterns I've refined after running this in production across multiple systems.
Why BullMQ over alternatives
BullMQ is the v4 rewrite of Bull. Key improvements: better TypeScript support, flow producers (job dependencies), and a cleaner worker model.
The alternatives I've considered:
- In-process queues (event emitter, etc.) — fine for low volume, die when the process restarts
- Database polling — works, but adds load to your DB and introduces latency
- SQS / Cloud pub-sub — valid for large scale, but adds operational overhead I don't need on most projects
BullMQ + Redis hits the right point on the complexity curve for 90% of what I build.
Module setup
// queue.module.ts
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.registerQueue({
name: 'notifications',
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 100,
removeOnFail: 500,
},
}),
],
exports: [BullModule],
})
export class QueueModule {}
The defaultJobOptions matter. Without them, failed jobs pile up in Redis forever. removeOnComplete: 100 keeps the last 100 completed jobs for debugging. removeOnFail: 500 gives you a window to inspect failures before they're gone.
Typed job definitions
The part most tutorials skip. Define your job payloads as discriminated unions:
// jobs.types.ts
export type NotificationJob =
| { type: 'email'; to: string; subject: string; template: string; data: Record<string, unknown> }
| { type: 'push'; userId: string; title: string; body: string }
| { type: 'sms'; phone: string; message: string };
Then use them in your producer:
// notification.producer.ts
@Injectable()
export class NotificationProducer {
constructor(@InjectQueue('notifications') private queue: Queue<NotificationJob>) {}
async sendEmail(payload: Extract<NotificationJob, { type: 'email' }>) {
await this.queue.add('send', payload, { priority: 1 });
}
}
And your worker narrows the type:
// notification.worker.ts
@Processor('notifications')
export class NotificationWorker extends WorkerHost {
async process(job: Job<NotificationJob>) {
switch (job.data.type) {
case 'email': return this.handleEmail(job.data);
case 'push': return this.handlePush(job.data);
case 'sms': return this.handleSms(job.data);
}
}
}
TypeScript ensures you handle every case. No silent misses.
What trips people up in production
Redis connection limits. Each worker opens its own connection. If you spin up many workers, you'll hit limits. Use connection pooling or a shared IORedis instance via BullModule.forRootAsync.
Concurrency vs parallelism. BullMQ's concurrency setting controls how many jobs a single worker processes simultaneously. Don't set it to 1 if your jobs do async I/O — you'll leave throughput on the table. I typically start at 5 and adjust based on observed behavior.
Stalled jobs. If a worker crashes mid-job, BullMQ marks it as stalled and re-queues it after stalledInterval. This means your jobs need to be idempotent. Design for "at least once" delivery, not "exactly once".
Observability
BullMQ without visibility is a black box. I use Bull Board to get a UI for queue state in development and staging. In production, I expose job counts as metrics (active, waiting, failed) to whatever monitoring stack the project uses.
That's the setup. It's not glamorous but it's reliable. The patterns above have held up across a pharmacy system processing thousands of prescription notifications and a logistics platform handling shipping webhook retries at scale.
If you're hitting a specific BullMQ problem, reach out — happy to talk through it.
Questions or thoughts? I'm always happy to talk through this stuff.
Get in touch →