Skip to content
Get started
Get started
Worker Error Handling, Retries and Backoff
View Markdown
guide · worker

When the processor throws.

A failed attempt is not a lost job. bunqueue counts the attempt, waits a widening backoff, tries again, and only after the budget runs out does the job become permanently dead.

Throwing inside the processor fails the current attempt; bunqueue retries with backoff (a growing wait between attempts) until attempts is exhausted:

const worker = new Worker('queue', async (job) => {
await riskyOperation(); // Just let errors throw, retries are automatic
}, { embedded: true });
worker.on('failed', (job, error) => {
console.warn(`Attempt ${job.attemptsMade + 1} failed`, error);
});

The failed listener fires after each broker-confirmed failed attempt, not only after retry exhaustion. The job snapshot was pulled before that failure, so the attempt that just failed is attemptsMade + 1 (both TypeScript packages), attempts + 1 (Python). PHP and Go do not expose maxAttempts on their Job view; use the queue’s DLQ operations for authoritative terminal-failure alerts. Rust and Elixir record per-job outcomes in normal processor control flow and expose transport/retry failures through telemetry; see the SDK guide.

A processing timeout can win while user code is still running. In that race, the broker has already recorded the failed attempt. Both TypeScript Workers treat the late processor result or exception as an acknowledged no-op: it emits neither a second completed/failed event nor a Worker error, and a newer retry lease remains free to finish normally.

Throw DelayedError from a TypeScript Worker processor when a job cannot run yet but has not failed. The Worker moves the job back to delayed: the attempt is not counted and no failed event fires.

import { DelayedError, Queue, Worker } from 'bunqueue/client';
const queue = new Queue('reports', { embedded: true });
const worker = new Worker('reports', async (job) => {
if (!(await upstreamReady())) throw new DelayedError('upstream not ready');
return buildReport(job.data);
}, { embedded: true });
// Postponed by at most 5 seconds, not by the 60-second base delay.
await queue.add('daily', { day: '2026-10-01' }, {
backoff: { type: 'fixed', delay: 60_000, maxDelay: 5_000 },
});

The wait is the job’s base backoff (the numeric backoff, or backoff.delay for the object form) with no exponential growth and no jitter; a base of 0 (or a negative one) falls back to 1000 ms. It is then capped at backoff.maxDelay, or at 1 hour when no cap is set. Embedded and TCP Workers compute the same wait.

The wait is never zero. maxDelay: 0 makes failed attempts retry immediately, but it does not apply to DelayedError: the job is not failing and no attempt is counted, so a zero wait would let a processor that keeps throwing DelayedError run the job again in a tight loop. A job with maxDelay: 0 waits its base backoff, capped at 1 hour.

GuideWhat it covers
WorkerCreate a worker and process your first job
Worker Concurrency and Batch PullingRun jobs in parallel and pull them in batches
The Job Object Inside a Worker ProcessorEverything the processor receives and can do
Worker Eventscompleted, failed, stalled and the rest
Worker LifecyclePause, resume and shut down without losing work
Heartbeats, Stall Detection and Lock OwnershipHeartbeats, stall recovery and lock ownership
SandboxedWorkerExperimental isolation for CPU-heavy handlers
WorkerOptions ReferenceEvery WorkerOptions field, with defaults