Building Resilient Background Jobs in NestJS with BullMQ

작성자

카테고리:

← 피드로
DEV Community · Menard Maranan · 2026-08-17 개발(SW)

Background jobs look simple right up until one of them dies silently in production and nobody notices for three days. A job that sends confirmation emails stops running. A job that syncs inventory data quietly falls behind. Nobody gets an error, because from the queue’s perspective, nothing “crashed” — the job just failed and nobody was watching.

Most BullMQ tutorials stop at “job added, job processed.” That’s fine for a demo, but it’s not what happens in a real system. In production, external APIs time out, workers restart mid-job, and retries without the right safeguards can make things worse, not better.

In this post, I’ll skip the basic setup tutorial and goes straight into the patterns that actually matter: retries that don’t cause a thundering herd, idempotency so retries don’t duplicate side effects, dead-letter queues for jobs that keep failing, concurrency limits that protect your database, and how to catch jobs that are “done” but still stuck.

BullMQ Retry DLQ flow

Quick Setup

If you haven’t wired up BullMQ in a NestJS app yet, here’s the minimum you need.

npm install @nestjs/bullmq bullmq ioredis

Enter fullscreen mode Exit fullscreen mode

// app.module.ts
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';

@Module({
  imports: [
    BullModule.forRoot({
      connection: {
        host: process.env.REDIS_HOST,
        port: Number(process.env.REDIS_PORT),
      },
    }),
    BullModule.registerQueue({
      name: 'notifications',
    }),
  ],
})
export class AppModule {}

Enter fullscreen mode Exit fullscreen mode

// notifications.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';

@Processor('notifications')
export class NotificationsProcessor extends WorkerHost {
  async process(job: Job): Promise<void> {
    // send the notification
  }
}

Enter fullscreen mode Exit fullscreen mode

That’s the happy path. Now let’s make it survive contact with production.

Retries with Exponential Backoff

The default instinct is to just add retries and move on:

await notificationsQueue.add('send-email', payload, {
  attempts: 5,
});

Enter fullscreen mode Exit fullscreen mode

The problem: without a backoff strategy, BullMQ retries as fast as it can. If the reason the job failed was a downstream API having a bad moment, five instant retries from every failed job in the queue can turn a blip into an outage — a thundering herd hitting a service that’s already struggling.

Exponential backoff spaces retries out so the downstream system gets room to recover:

await notificationsQueue.add('send-email', payload, {
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 1000, // 1s, 2s, 4s, 8s, 16s
  },
});

Enter fullscreen mode Exit fullscreen mode

For queues with high volume, add jitter on top of this. If a downstream outage causes thousands of jobs to fail at the same moment, exponential backoff alone still retries them all in near-lockstep — you’ve just delayed the thundering herd, not prevented it. A custom backoff strategy fixes that:

BullModule.registerQueue({
  name: 'notifications',
  defaultJobOptions: {
    backoff: {
      type: 'custom',
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

// custom-backoff.strategy.ts
export function customBackoffStrategy(attemptsMade: number): number {
  const base = Math.min(1000 * 2 ** attemptsMade, 30000);
  const jitter = Math.random() * 0.3 * base;
  return base + jitter;
}

Enter fullscreen mode Exit fullscreen mode

Register it on the worker’s settings.backoffStrategy option so retries spread out instead of clustering.

Idempotency Check: Making Retries Safe

Retries assume it’s safe to run the job again. That assumption breaks constantly. A job that charges a customer, sends an email, or writes to an external system can cause real damage if it runs twice — the first attempt may have actually succeeded downstream even though your worker crashed before it could confirm that success.

A naive fix is to check a local “already ran” flag before doing the work, then set it after:

async process(job: Job): Promise<void> {
  const key = `send-email:${job.data.userId}:${job.data.templateId}`;

  if (await this.idempotency.hasRun(key)) {
    return; // already sent, retry is a no-op
  }

  await sendEmail(job.data);
  await this.idempotency.markComplete(key);
}

Enter fullscreen mode Exit fullscreen mode

This looks safe, but it has a gap: if sendEmail succeeds and then markComplete fails — a crash, a dropped connection, a database hiccup — the next retry has no record that the email already went out. It checks hasRun, finds nothing, and sends a second email. The idempotency check itself isn’t idempotent.

The most reliable fix is to push the idempotency key to the provider you’re calling, if it supports one. Some transactional email providers (like Resend, and similar) accept an idempotency key on the send request. Passing the same key on every retry lets the provider dedupe the send at the source — so even if your local markComplete never runs, a retry is a safe no-op regardless of what your own state thinks happened.

async process(job: Job): Promise<void> {
  const key = `send-email:${job.data.userId}:${job.data.templateId}`;

  await sendEmail(job.data, { idempotencyKey: key });
  await this.idempotency.markComplete(key);
}

Enter fullscreen mode Exit fullscreen mode

This removes the ambiguous window entirely for the retry path. If markComplete fails, sendEmail runs again on retry, but the provider silently no-ops it instead of sending a duplicate.

Not every downstream system supports this, though. When it doesn’t, the safer pattern is to claim the key before doing the work, rather than checking and marking after:

async process(job: Job): Promise<void> {
  const key = `send-email:${job.data.userId}:${job.data.templateId}`;
  const status = await this.idempotency.getStatus(key);

  if (status === 'completed') {
    return; // confirmed already sent
  }

  if (status === 'pending') {
        // a previous attempt claimed this key but never confirmed completion —
        // this is the ambiguous case: it may or may not have actually sent.
        // don't blindly resend; flag it for manual reconciliation.
    throw new Error(`Ambiguous send state for${key} — needs manual check`);
  }

  await this.idempotency.claim(key); // insert with a unique constraint, status='pending'
  await sendEmail(job.data);
  await this.idempotency.markComplete(key); // status='completed'
}

Enter fullscreen mode Exit fullscreen mode

This doesn’t eliminate the ambiguous window — nothing fully can, without cooperation from the downstream system. What it does is make that window visible instead of silently resending: a job stuck in pending gets flagged for a human to check, rather than firing off a second email automatically. In most systems, an occasional job that needs a manual look is a far better trade-off than an automated retry that might double-send.

Dead-Letter Queues for Jobs That Keep Failing

Once a job exhausts its attempts, BullMQ marks it failed and moves on. If nothing is watching for that event, the job’s failure disappears into the queue’s history — it won’t page anyone, and it won’t show up unless someone happens to check.

A dead-letter queue (DLQ) gives failed jobs a place to land where they’re visible and replayable.

// dead-letter.listener.ts
import { OnQueueEvent, QueueEventsListener, QueueEventsHost } from '@nestjs/bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';

@QueueEventsListener('notifications')
export class NotificationsDeadLetterListener extends QueueEventsHost {
  constructor(@InjectQueue('notifications-dlq') private dlq: Queue) {
    super();
  }

  @OnQueueEvent('failed')
  async onFailed({ jobId, failedReason }: { jobId: string; failedReason: string }) {
    const job = await this.getJob(jobId);

    if (job && job.attemptsMade >= job.opts.attempts!) {
      await this.dlq.add('failed-notification', {
        originalData: job.data,
        failedReason,
        failedAt: new Date().toISOString(),
      });
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

From there, a small replay script lets you re-queue jobs from the DLQ once the underlying issue is fixed:

// replay-dlq.script.ts
async function replayDlq(dlq: Queue, notifications: Queue) {
  const jobs = await dlq.getJobs(['waiting', 'delayed']);

  for (const job of jobs) {
    await notifications.add('send-email', job.data.originalData);
    await job.remove();
  }
}

Enter fullscreen mode Exit fullscreen mode

The DLQ doesn’t fix the underlying failure — it turns “silently lost job” into “visible, replayable job,” which is the difference between a five-minute fix and a customer complaint three days later.

Concurrency Control and Backpressure

It’s tempting to crank up concurrency to clear a backlog faster:

@Processor('notifications', { concurrency: 50 })
export class NotificationsProcessor extends WorkerHost {
  // ...
}

Enter fullscreen mode Exit fullscreen mode

Fifty concurrent jobs sounds fine until each one opens a database connection, and your Postgres pool only has 20 connections available. The queue isn’t the bottleneck anymore — the database is, and now every other part of the app competes with the job queue for connections.

Size concurrency around your actual downstream constraints, not an arbitrary number:

@Processor('notifications', {
  concurrency: 10, // matched to available DB pool headroom
})
export class NotificationsProcessor extends WorkerHost {
  // ...
}

Enter fullscreen mode Exit fullscreen mode

If the queue talks to a rate-limited third-party API, BullMQ’s built-in limiter is a better fit than concurrency alone:

BullModule.registerQueue({
  name: 'notifications',
  limiter: {
    max: 100,
    duration: 60000, // max 100 jobs per minute
  },
});

Enter fullscreen mode Exit fullscreen mode

Detecting Stuck or Silently Hanging Jobs

The failure mode nobody plans for: a job that’s technically still “active” from BullMQ’s perspective, but is actually hung — stuck waiting on a call to an external service that never times out and never resolves. It won’t show up as failed. It’ll just sit there, occupying a worker slot indefinitely.

BullMQ’s stalled-job detection catches part of this — if a worker dies mid-job without renewing its lock, the job gets marked stalled and retried:

BullModule.registerQueue({
  name: 'notifications',
  settings: {
    stalledInterval: 30000,
    maxStalledCount: 2,
  },
});

Enter fullscreen mode Exit fullscreen mode

That handles a crashed worker. It doesn’t handle a worker that’s still alive but stuck in a call with no timeout. For that, the job itself needs a hard timeout:

async process(job: Job): Promise<void> {
  await Promise.race([
    sendEmail(job.data),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Job exceeded timeout')), 15000),
    ),
  ]);
}

Enter fullscreen mode Exit fullscreen mode

Without a timeout like this, “the job is still running” and “the job is stuck forever” look identical from the outside. This is usually where a team ends up building or buying observability into their job queues — once you’ve been paged for a queue that quietly backed up for six hours, you stop trusting “it’ll show up as failed eventually.”

Wrapping Up

None of these patterns are exotic — retries, idempotency, dead-letter queues, concurrency limits, and timeouts are well-known concepts. What’s easy to miss is that BullMQ gives you the primitives, not the judgment calls: how long to back off, whether a job is safe to retry, what “stuck” actually means for your workload. Those decisions are what separate a queue that works in a demo from one that survives production traffic.

If you’re setting up background jobs in NestJS for the first time, start with idempotency and a DLQ before you worry about tuning concurrency — those two alone catch the failure modes that actually wake people up at night.

원문에서 계속 ↗