Workhorse
Executing work

Retries

Give jobs an attempt budget and a persisted backoff policy that PostgreSQL applies consistently.

When a handler throws, the job usually is not finished — it has failed one attempt. PostgreSQL records the failure evidence, checks whether budget remains, and moves the job back to scheduled with a wake time before returning it to ready, or to a terminal failed outcome. This page covers who decides, when the next attempt runs, and what survives in between.

Set an attempt budget

EnqueueOptions.maxAttempts limits how many logical attempts one job may use. Each failure — a thrown error or a crashed worker's expired lease — spends one. When the budget is gone, the database deletes the runtime row and writes an immutable failed outcome.

await queue.enqueue("provider.sync", { accountId }, { maxAttempts: 5 });

The budget check happens in SQL, always. A worker override can change retry timing, but no client configuration can create extra attempts. You cannot accidentally configure infinite retries.

Persist the delay policy with the job

Retrying instantly is usually wrong: if a provider is down, hammering it just burns attempts. EnqueueOptions.retryPolicy stores a backoff shape with the immutable job definition, and PostgreSQL validates it at enqueue time. Three shapes exist:

const jitter = { type: "decorrelated-jitter", baseDelayMs: 1_000, maxDelayMs: 60_000 };
await queue.enqueue("provider.sync", { accountId }, { retryPolicy: jitter, maxAttempts: 5 });

Fixed and exponential policies use the same JSON field names in every SDK because PostgreSQL owns their validation and execution.

Prefer decorrelated jitter for anything that talks to an external service. When a thousand jobs fail together, jitter spreads their wake times so the retry wave cannot knock the recovering service over again.

Workhorse derives the jitter from stable job state — identity and attempt number — rather than fresh randomness. Replaying the same transition therefore selects the same delay, which keeps retry timing explainable after the fact.

One policy for both failure paths

A job can need a retry for two different reasons: its handler threw, or its worker died and the lease expired. An explicit policy applies to both paths, so a crashed process does not get different backoff behavior from a failing one.

Jobs without a policy keep older compatibility behavior, which differs between the two paths: a thrown error gets a legacy randomized backoff, and an expired lease retries immediately. Set a persisted policy whenever consistent timing matters.

Overrides exist for the cases that need them. Queue.fail accepts an explicit delay, and WorkerOptions.retryDelayMs takes a number or (attempt, job) => number | undefined. An explicit value wins — including an explicit 0 for "retry immediately" — and undefined defers to the persisted policy or the compatibility default.

What survives another attempt

The job ID, payload, tags, policy, and every saved checkpoint remain. PostgreSQL increments the attempt counter, and the next claim assigns a new fence token so a stale previous owner cannot write over the new attempt.

A retry is the same job having another go. A redrive is different: it happens only after terminal failure and creates a new job identity.

JobSnapshot.retryPolicy shows the normalized policy, and timeline events record each selected delay with its source. When an operator asks why a job is still scheduled, the evidence answers.

Next


Exact policy shapes, bounds, precedence, and retry transitions: architecture reference.