typescript 91 lines · 3 tabs

BullMQ worker with retries + dead-letter

Shared by codesnips Jan 2026
3 tabs
import { Queue } from "bullmq";
import IORedis from "ioredis";

export const connection = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {
  maxRetriesPerRequest: null,
});

export interface EmailJob {
  to: string;
  template: string;
  vars: Record<string, unknown>;
}

export const emailQueue = new Queue<EmailJob>("email", {
  connection,
  defaultJobOptions: {
    attempts: 5,
    backoff: { type: "exponential", delay: 1000 },
    removeOnComplete: { age: 3600, count: 1000 },
    removeOnFail: { age: 24 * 3600 },
  },
});

export const deadLetterQueue = new Queue("email:dead-letter", {
  connection,
  defaultJobOptions: { removeOnComplete: false, removeOnFail: false },
});
3 files · typescript Explain with highlit

This snippet shows how a resilient job pipeline is built with BullMQ, where transient failures are retried with backoff and permanently failed jobs are routed to a dedicated dead-letter queue rather than silently discarded.

In queues.ts, a single shared IORedis connection is created with maxRetriesPerRequest: null, which BullMQ requires for blocking commands used by workers. Two Queue instances are defined: the primary emailQueue and a separate deadLetterQueue. The defaultJobOptions encode the retry policy at enqueue time — attempts: 5 combined with an exponential backoff starting at one second means BullMQ automatically re-schedules a failed job with increasing delay (roughly 1s, 2s, 4s, and so on). removeOnComplete keeps Redis from growing unbounded by trimming succeeded jobs, while removeOnFail is left generous so failures remain inspectable.

The Worker in emailWorker.ts is the consumer. Its processor function does the real work and is deliberately allowed to throw: throwing is how a job signals failure to BullMQ, which then applies the configured backoff. The code distinguishes recoverable errors from permanent ones by throwing a plain Error for retryable cases and an UnrecoverableError for cases that should never be retried — the latter short-circuits the remaining attempts immediately. The concurrency option bounds how many jobs run in parallel per worker process.

The crucial piece is the failed event handler. BullMQ retries internally, but it does not move exhausted jobs anywhere by default. The handler checks job.attemptsMade against job.opts.attempts so it only fires the dead-letter logic once all retries are truly spent. At that point it re-enqueues the original payload onto deadLetterQueue, preserving the failedReason and originalId for later triage or manual replay.

Processing should be idempotent because a job can run more than once across retries; the sendEmail call keys on job.id so a duplicate delivery attempt is safe. dlqWorker.ts demonstrates the consuming side of the dead-letter queue — typically low-concurrency, used for alerting, logging to durable storage, or human review. This pattern is worth reaching for whenever work touches unreliable external systems and losing a job is unacceptable.


Related snips

Share this code

Here's the card — post it anywhere.

BullMQ worker with retries + dead-letter — share card
Link copied