Skip to the content.

Retry a request with backoff

A saga retries failed requests with setTimeout(fn, 1000) in a loop. Under a partial outage every client retries in lockstep and finishes the server off.

Implement retry(fn, options):

Workspace

Edit the module below and run the question's tests. Press Ctrl+Enter (or Cmd+Enter on a Mac) to run from the editor. Your draft is kept in this browser.

Solution

Approach 1: a loop with an abortable sleep

const sleep = (ms, signal) => new Promise((resolve, reject) => {
  if (signal?.aborted) return reject(signal.reason);
  const timer = setTimeout(resolve, ms);
  signal?.addEventListener("abort", () => {
    clearTimeout(timer);
    reject(signal.reason);
  }, { once: true });
});

export default async function retry(fn, options = {}) {
  const { retries = 3, baseMs = 100, maxMs = 30000, signal,
          shouldRetry = defaultShouldRetry, random = Math.random } = options;
  let lastError;
  for (let attempt = 1; attempt <= retries + 1; attempt++) {
    if (signal?.aborted) throw signal.reason;
    try {
      return await fn({ attempt, signal });
    } catch (err) {
      if (signal?.aborted) throw signal.reason;
      lastError = err;
      if (attempt > retries || !shouldRetry(err, attempt)) throw err;
      const ceiling = Math.min(maxMs, baseMs * 2 ** (attempt - 1));
      await sleep(random() * ceiling, signal);
    }
  }
  throw lastError;
}

The abortable sleep is the part naive versions miss. await sleep(8000) with no signal means a cancelled request keeps a timer alive for eight more seconds and then fires a retry nobody wants — which is how a component that unmounted keeps hitting your API.

Checking signal.aborted after the catch matters too: an aborted fetch rejects with an AbortError, and without that check it looks like a normal failure and gets retried.

Approach 2: inject the clock and the randomness

Note random = Math.random in the options. Jitter makes the delay non-deterministic by design, which makes it untestable by default. Injecting the source lets a test assert the exact delay sequence:

const delays = [];
await retry(failTwiceThenSucceed, {
  baseMs: 100, random: () => 1,                 // full jitter -> take the ceiling
  sleep: (ms) => { delays.push(ms); return Promise.resolve(); },
});
// delays === [100, 200]

Injecting sleep as well turns a test that would take seven seconds into one that takes none. Any retry implementation you cannot test without real time will not be tested.

Trade-offs

Full jitter vs equal jitter vs none. No jitter is the bug this question exists for: every client that failed at the same moment retries at the same moment, and the thundering herd knocks the server over again just as it recovers. Full jitter (random() * ceiling) spreads clients the widest and occasionally retries almost immediately. Equal jitter (ceiling/2 + random() * ceiling/2) keeps a floor. AWS’s published analysis puts full jitter ahead on both server load and completion time, and it is the simpler line of code.

What deserves a retry. A 400 will fail identically forever — retrying it wastes a round trip and hides the bug. A 429 should be retried, but honouring Retry-After when the server sends it beats any backoff you compute. Retrying a non-idempotent POST risks charging a card twice, which is why real retry policies key on the method and an idempotency key, not just the status.

Where this lives. Inside a middleware layer, one policy covers every request and no feature has to remember. Inside each saga, policies drift and half of them end up without jitter. The argument for the saga is per-operation control — and that is better expressed as options on the shared helper.

Total time budget. retries bounds the count, not the wall clock. With maxMs at 30s, four retries can hold a user for over a minute. A deadline (AbortSignal.timeout(10000)) is usually what you actually want, and it composes with the signal you already accept.