Exponential backoff for failed queue jobs: which curve, and where the wait lives

Fixed, exponential, full jitter or decorrelated: four backoff curves compared, plus a Node 22 worker on Infrai's queue that parks the wait in the broker, not in the process.

Use exponential growth with full jitter, cap the wait somewhere around 15 to 30 minutes, and hold the wait in the broker instead of in your worker process. On Infrai’s queue that decomposes into three moves: the queue already redelivers a failed message three times without any code from you, anything longer gets republished with a due timestamp in the payload, and whatever exhausts the curve lands in the dead-letter queue.

Most Node retry write-ups stop at the curve. The curve is the easy half.

Four curves and what 500 simultaneous failures do to each

A retry policy is two decisions: how fast the wait grows, and how much randomness you inject. Assume a base of 1 second and a cap of 900 seconds.

CurveWaits for attempts 1–5 (s)What a 500-job pile-up doesUse it when
Fixed interval30, 30, 30, 30, 30All 500 retry in the same second, five timesThe dependency has a hard, known recovery time
Exponential, no jitter1, 2, 4, 8, 16Herd stays intact, just spaced further apartSingle-producer jobs where collision can’t happen
Exponential + full jitterrand(0,1), rand(0,2), rand(0,4), rand(0,8), rand(0,16)Spread flattens immediately; mean wait halvesAlmost always — this is the default to reach for
Decorrelated jittermin(900, rand(1, prev×3))Flattest spread, climbs faster than plain doublingA shared dependency that’s genuinely saturated

Full jitter wins on the metric that matters after an outage: not “how long did one job wait” but “how many jobs hit the recovering service in the same second”. Halving the expected delay is a bonus, and the reason the Amazon-style sweep of these curves keeps landing on it. Decorrelated jitter is worth the extra state variable only when you’re retrying against something you know is capacity-bound.

Here’s the arithmetic in a form you can run:

// backoff-curves.mjs — node 22, no dependencies
const BASE_MS = 1000;
const CAP_MS = 900_000;

export const fixed = () => 30_000;
export const exponential = (attempt) => Math.min(CAP_MS, BASE_MS * 2 ** attempt);
export const fullJitter = (attempt) => Math.round(Math.random() * exponential(attempt));
export const decorrelated = (prevMs) => Math.min(CAP_MS, BASE_MS + Math.random() * (prevMs * 3 - BASE_MS));

function schedule(kind, attempts = 5) {
  let prev = BASE_MS;
  const out = [];
  for (let i = 0; i < attempts; i++) {
    const ms = kind === "decorrelated" ? decorrelated(prev) : { fixed, exponential, fullJitter }[kind](i);
    prev = ms;
    out.push(Math.round(ms / 1000));
  }
  return out;
}

for (const kind of ["fixed", "exponential", "fullJitter", "decorrelated"]) {
  console.log(kind.padEnd(14), schedule(kind).join("s, ") + "s");
}

A sleeping worker is a bug that only appears on deploy day

The pattern nearly every tutorial shows is await sleep(2 ** n * 1000) inside a catch block. It works on a laptop. In a container it means a 16-minute wait is 16 minutes of a process holding memory, holding a database connection, and holding an exclusive lease on the message — and the moment your platform sends SIGTERM for a rolling restart, that retry is gone. Nothing on disk records that a job was scheduled to run again, because the schedule only ever existed as a pending timer in a heap.

Park the wait somewhere durable and the same restart is a non-event.

What the queue already does for free

Create the queue with a dead-letter lane attached:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"invoice-sync","type":"standard","dlq":"invoice-sync-dlq"}'
{
  "ok": true,
  "data": {
    "name": "invoice-sync",
    "type": "standard",
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "visibility_timeout_default": 300,
    "max_receive_count": 3,
    "dlq_name": "invoice-sync-dlq"
  }
}

Read those two numbers together and you already have a retry policy: a consumed message is leased for 300 seconds, a worker that dies or simply never acks releases it, and after three deliveries it moves to invoice-sync-dlq. That’s fixed-interval backoff with a five-minute step, zero lines of code, and — this is the useful part — it’s the branch you get when a worker crashes mid-job rather than failing cleanly.

A limitation to design around: POST /v1/queue/nack returns {"nacked": true, "requeue": true} and requeue means immediately, so nack is a “hand this to another worker now” signal, not a delay primitive. There’s also a delay_seconds on publish that does park a message (we watched delayed_count hold one for 45 seconds on 2026-07-26), but it’s outside the documented request body and bad values come back as QUEUE_DELAY_INVALID, so don’t make a policy depend on it yet.

Republish with a due timestamp

The durable version of a delay is a field you control: put the earliest run time in the message body, and let the consumer skip anything not yet due.

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"invoice-sync","body":{"invoice_id":"inv_8812","attempt":0,"due_at":"2026-07-26T09:00:00Z"}}'

The worker below consumes, checks the due stamp, runs the job, and on a transient failure republishes itself one rung up the jittered curve. Note the field naming: the request body key is body, but the API hands the message back under payload, and queue.ack takes a receipt_handle that is the message_id string you just read. Get that wrong and the ack silently targets nothing.

// invoice-worker.mjs — node 22
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const API = "https://api.infrai.cc";
const QUEUE = "invoice-sync";
const MAX_ATTEMPTS = 6;
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };

async function post(path, payload) {
  const res = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const json = await res.json();
  if (!res.ok || json.ok === false) throw new Error(`${path} ${res.status}: ${json?.error?.message ?? "unknown"}`);
  return json.data;
}

const fullJitterSeconds = (attempt) => Math.round(Math.random() * Math.min(900, 2 ** attempt));

async function syncInvoice(id) {
  const res = await fetch(`https://api.example.com/invoices/${id}/sync`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "Idempotency-Key": `sync:${id}` },
    body: JSON.stringify({ invoice_id: id }),
    signal: AbortSignal.timeout(10_000),
  });
  if (res.status === 409 || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
    throw Object.assign(new Error(`permanent ${res.status}`), { permanent: true });
  }
  if (!res.ok) throw new Error(`transient ${res.status}`);
}

async function tick() {
  const { items } = await post("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
  for (const msg of items) {
    const job = msg.payload;
    if (job.due_at && Date.parse(job.due_at) > Date.now()) continue; // no ack: it re-hides
    try {
      await syncInvoice(job.invoice_id);
    } catch (err) {
      const attempt = (job.attempt ?? 0) + 1;
      if (!err.permanent && attempt < MAX_ATTEMPTS) {
        const wait = fullJitterSeconds(attempt);
        await post("/v1/queue/publish", {
          queue: QUEUE,
          body: { ...job, attempt, due_at: new Date(Date.now() + wait * 1000).toISOString() },
        });
        console.log(`invoice ${job.invoice_id}: attempt ${attempt} in ${wait}s`);
      } else {
        console.error(`invoice ${job.invoice_id}: giving up (${err.message})`);
      }
    }
    await post("/v1/queue/ack", { queue: QUEUE, receipt_handle: msg.message_id });
  }
  return items.length;
}

for (;;) {
  const n = await tick();
  if (!n) await sleep(2000);
}

Skipping a not-yet-due message without acking it is deliberate; it hides again for the visibility timeout and comes back on its own. The catch is that each of those re-hides burns one of the three deliveries, so keep the visibility timeout in the same order of magnitude as your typical wait — a 30-second lease with a 10-minute due stamp will dead-letter a perfectly healthy job.

Watching the curve run

curl -sS "https://api.infrai.cc/v1/queue/stats/invoice-sync" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"invoice-sync-dlq","max_messages":10}'

Worth flagging: GET /v1/queue/dlq/list/{queue} returned an empty items array for us even while dlq_count was 1, so read the dead-letter lane by consuming <queue>-dlq under its own name as above.

What retrying costs

Every republish is a publish at $0.00002 per message, verified 2026-07-26, while consume, ack, stats and dead-letter reads are free and rate-limited. Six attempts on 10,000 failed jobs is 50,000 extra publishes — about $1.00. New accounts start with $2 in credit. Rates on this surface have moved down rather than up, so check today’s number:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id=="queue.publish") | .billing'

When another tool is the better answer

BullMQ expresses this whole article as { attempts: 6, backoff: { type: "exponential", delay: 1000 } }, and if Redis is already in your stack that’s less machinery than a due-stamp convention. SQS gives you a per-message DelaySeconds up to 900 and a native redrive policy, which is the cleaner fit when your workers already run in AWS. The npm package exponential-backoff covers the in-process case honestly, for retries measured in seconds rather than hours.

The trade-off Infrai is making is different: HTTP-only workers, no broker client, and the storage, email and error-tracking the failed job needs next sitting on the same key and the same invoice. If all you need is a retry curve and you already run Redis, take BullMQ.

References

Browse more queue developer guides