What NestJS BullMQ is and when background jobs are the right tool

BullMQ is a job queue for Node.js that stores jobs, their state and their retry metadata in Redis. Producers add jobs to a named queue, and workers in one or more processes pull those jobs and run them. @nestjs/bullmq is the official NestJS integration. It registers queues as injectable providers and lets you declare workers as decorated classes, so queue code follows the same module and dependency-injection patterns as the rest of the application.

Teams use it to move slow or unreliable work off the request path. Sending email, delivering webhooks, generating reports and calling third-party APIs can all take seconds or fail intermittently. If that work runs inside an HTTP handler, the user waits and a timeout can lose the work. Once it is a queued job, the API can respond immediately, and the job can be retried, throttled and inspected on its own.

It fits backend and platform engineers running NestJS services who already operate Redis or are prepared to. Avoid it in three cases:

  • The result must exist before the response is sent.
  • The workload needs strict global ordering. Concurrency and retries reorder execution.
  • The workload needs exactly-once execution. Delivery is at-least-once, so a job can run more than once.

It is also a poor fit if your team will not treat Redis as critical infrastructure, because the queue's state lives there.

The rest of this article argues one point: retry, concurrency and priority settings determine how jobs behave under failure, so set them deliberately for each job type instead of leaving them at the defaults.

How it works: registering a queue, a producer and a WorkerHost processor

Four pieces make up the integration. BullModule.forRoot holds the Redis connection settings that every queue and worker in the module inherits. BullModule.registerQueue declares a named queue, and the name is the contract between producers and consumers. A producer service gets the queue through @InjectQueue('name') and calls queue.add(jobName, data, opts). That call writes the job to Redis and returns immediately, so an HTTP request is not blocked on slow work. On the consuming side, a class decorated with @Processor('name') extends WorkerHost and implements process(job). Nest creates a BullMQ Worker for that class at bootstrap.

A job moves through a small set of states. It sits in waiting, or in delayed if it was added with a delay or is between retry attempts. A worker then moves it to active, and it finishes as completed or failed. While a job is active, the worker holds a lock on it in Redis and renews that lock periodically. If the process dies or the event loop blocks long enough for the lock to expire, BullMQ treats the job as stalled and can hand it to another worker. Section 4 builds on this, because it is one source of duplicate execution.

The decorators hide several things. Concurrency and lock duration are passed as worker options to @Processor, and the defaults are conservative. Events such as failed and completed only reach you if you subscribe with @OnWorkerEvent. Every queue and worker opens its own Redis connections, so set connection options explicitly when you run many queues or connect to a managed Redis with tight connection limits.

import { Injectable, Module } from '@nestjs/common';
import { BullModule, InjectQueue, Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Job, Queue } from 'bullmq';

// Producer: enqueues work and returns as soon as the job is written to Redis.
@Injectable()
export class EmailProducer {
  constructor(@InjectQueue('email') private readonly emailQueue: Queue) {}

  async sendWelcome(userId: string) {
    return this.emailQueue.add('welcome', { userId }, { attempts: 3 });
  }
}

// Consumer: Nest creates a BullMQ Worker for this class at bootstrap.
// Worker options are set explicitly instead of relying on defaults.
@Processor('email', { concurrency: 5, lockDuration: 30_000 })
export class EmailProcessor extends WorkerHost {
  async process(job: Job<{ userId: string }>): Promise<void> {
    // While this runs, the job is 'active' and the worker renews its lock.
    // Avoid long synchronous work here: a blocked event loop lets the lock expire.
  }

  // Events are only delivered if you subscribe to them.
  @OnWorkerEvent('failed')
  onFailed(job: Job, err: Error) {
    console.error(`job ${job.id} failed (attempt ${job.attemptsMade}): ${err.message}`);
  }
}

@Module({
  imports: [
    // Shared connection settings inherited by every queue and worker below.
    BullModule.forRoot({ connection: { host: 'redis.internal', port: 6379 } }),
    // The queue name is the contract between producer and processor.
    BullModule.registerQueue({ name: 'email' }),
  ],
  providers: [EmailProducer, EmailProcessor],
})
export class EmailModule {}

A minimal NestJS module that wires a shared Redis connection, a named queue, a producer that calls queue.add, and a WorkerHost processor with explicit concurrency and lock duration and a subscribed failed event.

Retries and backoff: separating transient failures from permanent ones

Retry behaviour is set per job through two options. attempts is the total number of tries, including the first. backoff sets the wait between tries: fixed waits the same delay every time, and exponential doubles the wait after each failure, starting from the base delay. Any error thrown in process(), or any promise it rejects, counts as a failed attempt. Inside the processor, job.attemptsMade shows how many attempts have already run. Use it to tag log lines and to decide what to do on the final try, such as alerting someone or switching to a degraded path.

Not every failure should be retried, so classify each error before deciding:

Failure Examples Action
Transient Timeouts, HTTP 5xx, connection resets Throw a normal error and let backoff retry it
Permanent Validation errors, HTTP 4xx, missing records Throw UnrecoverableError

Throwing UnrecoverableError moves the job straight to failed, whatever attempts remain. Without it, bad input uses up every retry and delays the moment someone sees the problem.

Here is an illustrative estimate with assumed inputs: attempts: 5 and an exponential backoff with a 1,000 ms base. The four retries wait 1 s, 2 s, 4 s and 8 s, which adds up to about 15 s of scheduled delay before the job finally fails. Raise the base delay to 30 s and the total becomes about 7.5 minutes.

Retries also multiply traffic to a shared dependency that is already struggling. If a thousand jobs fail together, they all retry on the same exponential schedule and hit the dependency in synchronized bursts. A custom backoff strategy that adds random jitter spreads those retries out over time. A circuit breaker goes further: once failures pass a threshold, it stops calling the dependency entirely until the dependency recovers.

import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job, UnrecoverableError } from 'bullmq';

// Producer side, e.g. in a service with an injected queue:
// await this.queue.add('charge', { orderId: 'ord_123' }, {
//   attempts: 5,                                   // total tries, including the first
//   backoff: { type: 'exponential', delay: 1000 }, // waits 1s, 2s, 4s, 8s between tries
// });

@Processor('payments')
export class PaymentsProcessor extends WorkerHost {
  private readonly logger = new Logger(PaymentsProcessor.name);

  async process(job: Job<{ orderId: string }>): Promise<void> {
    const attempt = job.attemptsMade + 1; // attemptsMade counts earlier attempts
    const isLast = attempt >= (job.opts.attempts ?? 1);
    this.logger.log(`job=${job.id} order=${job.data.orderId} attempt=${attempt}`);

    if (!job.data.orderId) {
      throw new UnrecoverableError('missing orderId'); // bad input: fail now, skip retries
    }

    // A timeout rejects the promise, which counts as a normal (retryable) failure
    const res = await fetch(` {
      method: 'POST',
      signal: AbortSignal.timeout(5000),
    });

    if (res.status >= 400 && res.status < 500 && res.status !== 429) {
      // 4xx (except rate limiting): the same request will fail again
      throw new UnrecoverableError(`permanent: HTTP ${res.status}`);
    }
    if (!res.ok) {
      if (isLast) this.logger.error(`job=${job.id} retries exhausted, alerting`);
      throw new Error(`transient: HTTP ${res.status}`); // 5xx/429: let backoff retry
    }
  }
}

A NestJS BullMQ processor that uses job.attemptsMade for logging and last-attempt handling, retries transient failures through exponential backoff, and throws UnrecoverableError for permanent ones.

Idempotent handlers under at-least-once delivery

Every retry described above means the same job runs again, and retries are not the only cause. If a worker crashes, or blocks the event loop long enough that its lock on a job expires, BullMQ marks the job as stalled and returns it to the queue for another worker. The first worker may have already charged the card before it died. BullMQ therefore gives you at-least-once delivery. Design every handler on the assumption that it will run twice.

Deduplicate at enqueue time

Pass a custom jobId derived from the business event, such as order-1234-confirmation, instead of letting BullMQ generate one. If a job with that ID already exists in Redis, the new add call is ignored. This stops duplicates caused by double-clicks or a producer retrying its own request.

The protection lasts only while the job exists in Redis. With removeOnComplete: true, the job is deleted as soon as it finishes, and the same ID can be enqueued again right away. Keeping completed jobs for a set age or count extends the deduplication window, but Redis memory use grows with it. Either way, this is a short-term filter and does not guarantee uniqueness.

Make side effects idempotent

Durable protection belongs at the point of the side effect. Before charging a card, insert a row keyed by the idempotency key under a unique constraint, or check for an existing one. Then send the same key to the payment API so the provider rejects a duplicate charge. Many email providers have no such mechanism. For email, record a "sent" marker in the database and accept that a crash between sending and recording can still produce a duplicate.

import { Injectable } from '@nestjs/common';
import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job, Queue } from 'bullmq';
import { Db, PaymentsClient } from './deps'; // placeholder app interfaces

@Injectable()
export class OrderProducer {
  constructor(@InjectQueue('payments') private readonly queue: Queue) {}

  async enqueueCharge(orderId: string, amountCents: number) {
    // Same business event -> same jobId; a duplicate add is ignored while the job is still in Redis.
    await this.queue.add('charge', { orderId, amountCents }, {
      jobId: `order-${orderId}-charge`,
      attempts: 5,
      // Keep completed jobs for 24h (max 10k) to widen the dedup window, at a Redis memory cost.
      removeOnComplete: { age: 24 * 3600, count: 10_000 },
    });
  }
}

@Processor('payments')
export class ChargeProcessor extends WorkerHost {
  constructor(private readonly db: Db, private readonly payments: PaymentsClient) { super(); }

  async process(job: Job<{ orderId: string; amountCents: number }>) {
    const key = `order-${job.data.orderId}-charge`;
    // Durable record under a UNIQUE constraint; survives job removal from Redis.
    await this.db.query(
      `INSERT INTO charges (idempotency_key, status) VALUES ($1, 'pending')
       ON CONFLICT (idempotency_key) DO NOTHING`, [key]);
    const { rows } = await this.db.query(
      'SELECT status FROM charges WHERE idempotency_key = $1', [key]);
    if (rows[0].status === 'charged') return; // a previous run (retry or stalled) finished
    // Same key on every attempt, so the provider rejects a second charge if we crashed after charging.
    await this.payments.charge({ amountCents: job.data.amountCents, idempotencyKey: key });
    await this.db.query(
      `UPDATE charges SET status = 'charged' WHERE idempotency_key = $1`, [key]);
  }
}

A NestJS producer deduplicates enqueues with a business-derived jobId and a time-bounded removeOnComplete window. The processor uses a database row and a provider idempotency key so that retried or stalled jobs do not charge the card twice.

Concurrency, rate limiting and priorities: protecting downstream systems

Each worker processes up to concurrency jobs at once, so total in-flight work is roughly workers × concurrency. All of a worker's jobs share one Node.js event loop. For I/O-bound jobs such as HTTP calls or queries, a concurrency of 10–50 is reasonable because most of the time is spent waiting. For CPU-bound jobs, keep concurrency near 1 per process and scale with processes or sandboxed processors, since extra concurrency only lengthens the event-loop stalls.

Scaling pods multiplies load on downstream systems. Illustrative estimate: assume 8 pods at concurrency 10, where every job holds one database connection. That is 80 concurrent connections against a database that, in this example, allows 60. The queue-level limiter (max jobs per duration) caps throughput across all workers of a queue, no matter how many pods you add.

The limit applies to a queue, not to a downstream resource. If three queues call the same API, each one enforces its own budget. You need shared coordination outside BullMQ, such as one queue in front of the API or a common token bucket in Redis.

The priority option uses lower numbers for higher priority. Jobs without a priority are processed before prioritized ones. A steady stream of high-priority jobs can starve low-priority work indefinitely. Prioritized jobs are also kept in a sorted set, which makes adding them costlier than adding plain jobs.

Approach Isolation Starvation risk Operational cost
One queue with priorities Shared workers and limits High under sustained load One deployment
Separate queues with dedicated workers Independent concurrency and limits None between queues More workers to size and monitor
import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { Job, Queue } from 'bullmq';

// I/O-bound worker: each job mostly waits on the database, so moderate
// concurrency is fine. In-flight jobs per pod = concurrency (10).
// The limiter caps the whole queue at 50 jobs per second, however many
// pods run this processor, so scaling pods does not raise downstream load.
@Processor('reports', {
  concurrency: 10,
  limiter: { max: 50, duration: 1000 },
})
export class ReportsProcessor extends WorkerHost {
  async process(job: Job<{ accountId: string }>): Promise<void> {
    // Placeholder for a query that holds one DB connection while it runs.
    await this.buildReport(job.data.accountId);
  }

  private async buildReport(accountId: string): Promise<void> {
    /* query the database for accountId */
  }
}

@Injectable()
export class ReportsProducer {
  constructor(@InjectQueue('reports') private readonly queue: Queue) {}

  // Lower number = higher priority. Prioritized jobs live in a sorted set,
  // so a steady stream of priority 1 jobs can starve priority 10 jobs.
  enqueueInteractive(accountId: string) {
    return this.queue.add('report', { accountId }, { priority: 1 });
  }

  enqueueNightly(accountId: string) {
    return this.queue.add('report', { accountId }, { priority: 10 });
  }
}

A NestJS BullMQ processor with per-worker concurrency and a queue-wide rate limiter, and a producer that adds jobs at two priority levels where the lower number runs first.

Failed jobs, stalled jobs and graceful shutdown in production

Unbounded history is the most common way a queue exhausts Redis memory. Set removeOnComplete and removeOnFail in default job options, using a count or an age limit. Keep failed jobs longer than completed ones, because they are what you will need to debug.

Dead letters and alerting

Attach a failed handler to the worker, or run a QueueEvents listener in a separate process when the alerting code should not live inside the workers. When a job has used all of its attempts, copy its name, data and failure reason to a dedicated dead-letter queue or table that nothing consumes automatically. An engineer can then inspect it, fix the cause and replay it on purpose. Replay is safe because the handlers are idempotent.

Locks and stalls

A worker holds a lock on each active job and renews it periodically. If CPU-bound work blocks the event loop, renewal stops, the lock expires, and stall detection returns the job to the queue. Another worker then runs the job a second time. To fix this, move the CPU work out of the event loop. Sandboxed processors run each job in a child process, which keeps lock renewal working at the cost of extra process overhead and serializing job data across the process boundary. Use them only for CPU-heavy jobs.

Shutdown

Call app.enableShutdownHooks() in main.ts. On SIGTERM, workers then close, stop taking new jobs and finish the ones in flight. Set the orchestrator's termination grace period longer than your slowest job, or that job will be killed mid-run.

Redis and observability

  • Persistence: enable AOF or RDB persistence so a restart does not lose queued jobs.
  • Eviction policy: set maxmemory-policy noeviction. With other policies, Redis can silently delete queue keys when memory is tight.
  • Memory: alert on memory usage well before you reach the limit.
  • Logs: include the job ID and a correlation ID carried from the original request in every job log line.
  • Metrics: track waiting-queue depth and failure rate for each queue.

Alternatives and impact on cost and engineering time

Those signals cover BullMQ itself. Before adding a queue, compare it with simpler or heavier options.

Option Delivery if a process crashes Operational burden Ordering Fit for NestJS teams
In-process async work or scheduled tasks Work in progress is lost None beyond the app Effectively none Fine for best-effort work you can afford to lose
Database-backed job table At-least-once with careful row locking Low, since you reuse the existing database Whatever your query enforces Good when the job must commit in the same transaction as business data
BullMQ on Redis At-least-once, with stalled-job recovery Moderate: you run and monitor Redis FIFO per queue, but priorities and concurrency relax it Strong, with first-class module integration
Managed or broker-based queue Usually at-least-once Low if managed, high if self-hosted Depends on the broker Better for cross-service or cross-language messaging

BullMQ has real costs:

  • You operate Redis. Persistence and memory settings now affect job durability.
  • Failures surface minutes after the request that caused them, which makes debugging harder.
  • Making handlers idempotent and setting up monitoring takes engineering time.

It saves time elsewhere. Retries, backoff, rate limiting, priorities and failed-job retention replace hand-rolled retry loops and cron sweeper scripts that you would otherwise write, test and debug yourself. Settings chosen deliberately per job type also shorten incidents. When the defaults are made explicitly, on-call engineers debug known behaviour instead of discovering it.

Checklist for each new job type

  • Attempts and backoff strategy
  • Which errors are permanent and should fail without retrying
  • Idempotency key
  • Worker concurrency and rate limit
  • Priority relative to other jobs in the queue
  • Job timeout and stalled-job tolerance
  • How many completed and failed jobs to retain
  • Alert threshold for failures and queue depth

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 route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

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.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • 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.