Skip to content

Full Stack

Background Job Processing with BullMQ and Redis: A Production Playbook for 2026

August 25, 202612 min readRasel Hossain
Background Job Processing with BullMQ and Redis: A Production Playbook for 2026

Quick answer

BullMQ is a Redis-backed job queue library for Node.js that handles retries, rate limiting, scheduled jobs, and scales to millions of jobs per minute. Combined with NestJS, it provides a production-ready foundation for background processing in modern applications.

Background Job Processing with BullMQ and Redis: A Production Playbook for 2026

When I first started building production systems back in 2020, I treated background jobs like an afterthought — a setTimeout here, a cron job there, and a prayer that nothing crashed at 2 AM. Six years and 168+ projects later, I can tell you with absolute certainty: if your application does anything beyond CRUD, it needs a proper job queue. Email sends, image processing, webhook deliveries, AI inference calls, PDF generation — all of these belong in a background job system, not in your request lifecycle.

In 2026, the Node.js ecosystem has matured significantly, and the de facto standard for background job processing is BullMQ — a Redis-backed, TypeScript-first queue library that powers everything from startups to enterprises. In this playbook, I'll walk you through how to design, build, and operate a BullMQ-based job system in production using NestJS, covering retries, rate limiting, scheduled jobs, and observability patterns I've battle-tested with international clients.

background job processing - Image 2

Why BullMQ Over Other Queue Systems?

Before we dive into code, let's address the elephant in the room. The Node.js ecosystem has several queue options:

background job processing - Image 3

  • BullMQ — Redis-based, modern, actively maintained, TypeScript-native
  • Bull (v3) — The predecessor, now in maintenance mode
  • Agenda — MongoDB-based, simpler but less performant
  • Bee-Queue — Lightweight but limited features
  • Temporal/Cadence — Heavyweight, polyglot, overkill for most use cases

For 95% of Node.js applications, BullMQ hits the sweet spot. It's built on Redis (which you probably already have), offers first-class TypeScript support, and provides battle-tested features like delayed jobs, repeatable jobs, rate limiting, and worker concurrency out of the box. In my experience building automation pipelines for e-commerce and SaaS clients, BullMQ handles tens of thousands of jobs per minute without breaking a sweat.

Setting Up BullMQ with NestJS

Let's start with the foundation. I'm a big fan of NestJS for production Node.js applications because of its modular architecture, dependency injection, and excellent TypeScript support. Here's how I typically structure a BullMQ integration.

Step 1: Install Dependencies

npm install @nestjs/bullmq bullmq
npm install --save-dev @types/bull

You'll also need a Redis instance. I recommend Redis 7.x or later, ideally managed (AWS ElastiCache, Upstash, or Redis Cloud) for production workloads.

Step 2: Configure the Queue Module

Create a dedicated module for your queues. I prefer one module per queue domain rather than a single monolithic queues.module.ts:

// email.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { EmailProcessor } from './email.processor';
import { EmailService } from './email.service';

@Module({
  imports: [
    BullModule.registerQueue({
      name: 'email',
      defaultJobOptions: {
        attempts: 3,
        backoff: {
          type: 'exponential',
          delay: 1000,
        },
        removeOnComplete: { count: 1000 },
        removeOnFail: { count: 5000 },
      },
    }),
  ],
  providers: [EmailProcessor, EmailService],
  exports: [EmailService],
})
export class EmailModule {}

Notice the defaultJobOptions — this is where production-grade defaults live. I always configure retries with exponential backoff, and I cap job retention to prevent Redis from growing unbounded.

Step 3: Create a Processor

Workers (called "processors" in BullMQ lingo) are where your actual work happens. Here's a robust email processor:

// email.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Counter, Histogram } from 'prom-client';

interface EmailJobData {
  to: string;
  subject: string;
  template: string;
  context: Record<string, any>;
}

@Processor('email', {
  concurrency: 5,
  limiter: {
    max: 10,
    duration: 1000,
  },
})
export class EmailProcessor extends WorkerHost {
  private readonly logger = new Logger(EmailProcessor.name);

  constructor(
    private readonly mailer: MailerService,
    @InjectMetric('jobs_processed_total') private jobsCounter: Counter<string>,
    @InjectMetric('job_duration_seconds') private jobDuration: Histogram<string>,
  ) {
    super();
  }

  async process(job: Job<EmailJobData>): Promise<void> {
    const timer = this.jobDuration.startTimer({ queue: 'email' });

    try {
      this.logger.log(`Processing email job ${job.id} to ${job.data.to}`);

      // Move job to delayed state if rate limited
      if (job.attemptsMade > 0) {
        this.logger.warn(`Retry attempt ${job.attemptsMade} for job ${job.id}`);
      }

      await this.mailer.sendMail({
        to: job.data.to,
        subject: job.data.subject,
        template: job.data.template,
        context: job.data.context,
      });

      this.jobsCounter.inc({ queue: 'email', status: 'success' });
    } catch (error) {
      this.jobsCounter.inc({ queue: 'email', status: 'failed' });
      this.logger.error(`Email job ${job.id} failed: ${error.message}`, error.stack);
      throw error; // Re-throw to trigger retry
    } finally {
      timer();
    }
  }
}

The limiter configuration is critical — it prevents your worker from overwhelming downstream services like SendGrid or AWS SES. The Prometheus metrics give you production-grade observability.

Production-Grade Job Patterns

Let me share the patterns I use most often in client projects.

Pattern 1: The Fan-Out Pattern

When you need to process a batch of items independently, don't process them in a single job. Instead, fan out into multiple jobs:

async processBatch(userIds: string[]) {
  const jobs = userIds.map((userId) => ({
    name: 'send-notification',
    data: { userId, message: 'Weekly digest ready' },
  }));

  await this.notificationQueue.addBulk(jobs);
}

Why? Because if one item fails, you don't lose the whole batch. Each job has its own retry logic, and you can scale workers horizontally to process them in parallel.

Pattern 2: The Saga Pattern with Parent/Child Jobs

For complex workflows, use BullMQ's parent-child job relationships:

const parent = await this.workflowQueue.add('order-workflow', { orderId });

const children = await this.workflowQueue.addBulk([
  { name: 'charge-payment', data: { orderId }, parent: { id: parent.id } },
  { name: 'reserve-inventory', data: { orderId }, parent: { id: parent.id } },
  { name: 'send-confirmation', data: { orderId }, parent: { id: parent.id } },
]);

// In processor:
async process(job: Job) {
  if (job.name === 'order-workflow') {
    const children = await job.getChildrenValues();
    if (Object.values(children).every(c => c.status === 'completed')) {
      await this.finalizeOrder(job.data.orderId);
    }
  }
}

Pattern 3: Scheduled Jobs (Cron)

BullMQ supports repeatable jobs natively — no need for separate cron libraries:

// Schedule a daily cleanup job
await this.maintenanceQueue.add(
  'cleanup-expired-sessions',
  {},
  {
    repeat: {
      pattern: '0 2 * * *', // Every day at 2 AM
      tz: 'America/New_York',
    },
    jobId: 'daily-cleanup', // Prevents duplicates
  },
);

The jobId is crucial — it ensures you don't accidentally create duplicate scheduled jobs on every application restart.

Pattern 4: Priority Queues

Not all jobs are equal. Use priorities to ensure critical work gets processed first:

await this.queue.add('send-receipt', { orderId }, { priority: 1 }); // High priority
await this.queue.add('send-newsletter', { userId }, { priority: 10 }); // Low priority

Lower numbers = higher priority. Be careful though — high-priority jobs can starve low-priority ones if the queue is consistently busy. Use separate queues for different priority tiers.

Observability: The Difference Between Toy and Production

A job queue without observability is a black box waiting to fail. Here's my production monitoring stack:

1. BullMQ Board

The official BullMQ Board gives you a real-time UI for inspecting queues:

import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';

const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');

createBullBoard({
  queues: [new BullMQAdapter(emailQueue), new BullMQAdapter(notificationQueue)],
  serverAdapter,
});

2. Prometheus Metrics

Export the metrics I showed earlier to Prometheus and visualize in Grafana. The key metrics are:

  • jobs_processed_total{queue, status} — Counter
  • job_duration_seconds{queue} — Histogram
  • jobs_in_queue{queue, state} — Gauge (waiting, active, delayed, failed)

3. Structured Logging

Always log job lifecycle events with context:

async process(job: Job) {
  this.logger.log({
    msg: 'Job started',
    jobId: job.id,
    queue: job.queueName,
    attempt: job.attemptsMade + 1,
    data: job.data,
  });
  // ...
}

4. Dead Letter Queue Inspection

Failed jobs accumulate in Redis. Build a dashboard or alert for jobs with attemptsMade >= maxAttempts. I typically set up a cron that pages me if more than 1% of jobs are failing in a 5-minute window.

Scaling Strategies for 2026

When your application grows, here's how to scale BullMQ:

Horizontal Worker Scaling

BullMQ workers are stateless — just run more of them. Use Kubernetes HPA based on Redis queue depth:

metrics:
  - type: Pods
    metric:
      name: bullmq_queue_depth
    target:
      type: AverageValue
      averageValue: "100"

Redis Sharding

For very high throughput (>50K jobs/min), consider Redis Cluster. BullMQ supports it natively, but you'll need to configure prefix correctly to avoid key collisions.

Separate Queues for Different Concerns

Don't put everything in one mega-queue. Separate by:

  • Domain (email, notifications, billing)
  • Latency requirement (real-time vs batch)
  • Resource profile (CPU-heavy vs IO-heavy)

This lets you scale workers independently and prevents one slow job type from blocking others.

Common Pitfalls I See in Production

After deploying BullMQ for dozens of clients, here are the mistakes I see repeatedly:

  1. Not setting removeOnComplete / removeOnFail — Redis fills up with completed job data, eventually causing OOM.
  2. Ignoring graceful shutdown — Always close workers gracefully on SIGTERM to finish in-progress jobs.
  3. Putting too much data in job payloads — Keep payloads small; store large data in S3 and pass URLs.
  4. Forgetting idempotency — Jobs may run multiple times due to retries. Design processors to be idempotent.
  5. No alerting on dead letter queues — Failed jobs that exhaust retries need human attention.

Graceful Shutdown Example

// main.ts
app.enableShutdownHooks();

// In your processor:
async onModuleDestroy() {
  await this.worker.close(); // Waits for active jobs to finish
}

Wrapping Up

Background job processing is one of those things that seems simple until it isn't. By using BullMQ with Redis and NestJS, you get a production-grade foundation that scales from your first thousand users to your first million. The patterns I've shared — fan-out, sagas, scheduled jobs, and proper observability — are the same ones I use in client engagements ranging from e-commerce automation to AI-powered SaaS platforms.

If you're building a system that needs reliable background processing in 2026, invest the time to set up BullMQ correctly from day one. Your future self (and your on-call rotation) will thank you.

Need help architecting a job queue system for your application? I specialize in building scalable Node.js backends with NestJS, BullMQ, and Redis. Let's talk about your project — check out my Fiverr profile or reach out directly through my portfolio.

FAQ

What is the difference between BullMQ and Bull?

BullMQ is the modern, actively-maintained successor to Bull. It has better TypeScript support, improved performance, and supports features like parent-child jobs and sandboxed processors. Bull is now in maintenance mode and only receives critical security updates.

How many jobs can BullMQ handle?

On a modest Redis instance (4GB RAM, 2 vCPU), BullMQ reliably handles 10,000-50,000 jobs per minute depending on job complexity. With Redis Cluster and horizontal worker scaling, throughput can exceed 1 million jobs per minute.

Should I use BullMQ or Temporal for my Node.js application?

For most Node.js use cases, BullMQ is the right choice — it's simpler, Redis-based, and integrates seamlessly with the Node ecosystem. Temporal is better for complex multi-service workflows requiring strong consistency guarantees, but adds significant operational complexity.

How do I handle failed jobs in BullMQ?

Configure attempts and backoff in job options. Failed jobs that exhaust retries remain in the "failed" state for inspection. Use Bull Board UI or query queue.getFailed() programmatically to handle them. For critical failures, integrate with Sentry or PagerDuty.

Is BullMQ production-ready?

Yes. BullMQ powers companies like Shopify, GitLab, and thousands of production systems. It has 99.9%+ uptime when paired with managed Redis, and the library is actively maintained by Taskforce.sh.

How-To: Set Up BullMQ in a NestJS Project

  1. Install dependencies and configure Redis — Run npm install @nestjs/bullmq bullmq and provision a Redis 7.x instance. Update your environment variables with the Redis connection URL.

  2. Register the queue module — Create a dedicated module file (e.g., email.module.ts) and use BullModule.registerQueue() with production-safe defaults including retry attempts, exponential backoff, and job retention limits.

  3. Build the processor with metrics — Extend WorkerHost, set concurrency and limiter options, and integrate Prometheus counters and histograms to track job success rates and processing duration.

  4. Add scheduled jobs and observability — Use the repeat option with cron patterns for scheduled tasks, mount Bull Board UI for visual debugging, and set up alerts on queue depth and failure rates.

  5. Configure graceful shutdown and deploy — Implement onModuleDestroy() to close workers cleanly, deploy with Kubernetes HPA scaling based on Redis queue depth, and verify idempotency for all job types.

#NestJS#Node.js#Backend#BullMQ#Redis

How to do it

  1. 1

    Install dependencies and configure Redis

    Run `npm install @nestjs/bullmq bullmq` to install the packages. Provision a Redis 7.x instance (managed or self-hosted) and add the connection URL to your environment variables. For production, enable persistence (AOF) to survive Redis restarts without losing queued jobs.

  2. 2

    Register the queue module with production defaults

    Create a dedicated module like `email.module.ts` and use `BullModule.registerQueue()` with production-safe defaults: set `attempts: 3` for retries, configure exponential `backoff` with a 1-second base delay, and set `removeOnComplete: { count: 1000 }` plus `removeOnFail: { count: 5000 }` to prevent Redis from growing unbounded.

  3. 3

    Build the processor with metrics and rate limiting

    Extend `WorkerHost` and set `concurrency: 5` plus a `limiter` to prevent overwhelming downstream services. Wrap your job logic in try/catch blocks, integrate Prometheus counters for success/failure tracking, and use a histogram to measure job duration. Always re-throw errors to trigger BullMQ's retry mechanism.

  4. 4

    Add scheduled jobs and observability tooling

    Use the `repeat` option with cron patterns for scheduled tasks like daily cleanups. Always set a `jobId` to prevent duplicate scheduled jobs on restart. Mount Bull Board UI at an admin route for visual debugging, and export Prometheus metrics to Grafana for real-time dashboards showing queue depth, throughput, and failure rates.

  5. 5

    Configure graceful shutdown and deploy for scale

    Implement `onModuleDestroy()` in your processor to call `worker.close()`, which waits for active jobs to complete before shutdown. Deploy with Kubernetes and configure HPA to scale workers based on Redis queue depth. Verify all job processors are idempotent (safe to retry) and set up PagerDuty alerts for sustained failure rates above 1%.

Frequently asked questions

What is the difference between BullMQ and Bull?

BullMQ is the modern, actively-maintained successor to Bull. It offers better TypeScript support, improved performance, and supports advanced features like parent-child jobs and sandboxed processors. Bull is now in maintenance mode and only receives critical security updates. New projects should always use BullMQ.

How many jobs can BullMQ handle per minute?

On a modest Redis instance (4GB RAM, 2 vCPU), BullMQ reliably handles 10,000-50,000 jobs per minute depending on job complexity. With Redis Cluster and horizontal worker scaling across multiple nodes, throughput can exceed 1 million jobs per minute. Real bottlenecks are usually downstream services, not BullMQ itself.

Should I use BullMQ or Temporal for my Node.js app?

For most Node.js use cases, BullMQ is the right choice — it's simpler, Redis-based, and integrates seamlessly with the Node ecosystem. Temporal is better for complex multi-service workflows requiring strong consistency guarantees across language boundaries, but adds significant operational complexity and is often overkill for single-language stacks.

How do I handle failed jobs in BullMQ?

Configure `attempts` and `backoff` options on each job. Failed jobs that exhaust retries remain in the 'failed' state in Redis for inspection. Use Bull Board UI for visual debugging or query `queue.getFailed()` programmatically. For critical failures, integrate with Sentry, PagerDuty, or set up cron-based alerts when failure rates exceed thresholds.

Is BullMQ production-ready?

Yes, BullMQ is production-ready and powers companies like Shopify and GitLab. When paired with managed Redis (AWS ElastiCache, Upstash, Redis Cloud), it offers 99.9%+ uptime. The library is actively maintained by Taskforce.sh with regular releases and a strong community.

More articles

Related reading from the same areas — practical notes on shipping software.

View all articles

Liked the article?

Have a similar problem in your business? Let's talk about building the fix.

Start a project