Exponential Backoff With Jitter in the Browser

This is the concrete build: a fetch wrapper that retries 429/503 with full-jitter exponential backoff, honors Retry-After when the server sends it, cancels cleanly via AbortController, stops after a maximum number of attempts, and surfaces a give-up state to the UI. It implements the strategy described in exponential backoff and UX, and it’s the piece you actually ship in front of a rate-limited API.

The problem in concrete numbers

A checkout page calls a payments API limited to 50 requests/second per tenant. During a flash sale, 800 browser tabs submit within the same second and a third get 429. With no jitter, all ~270 failed tabs recompute base·2⁰ = 500 ms and retry at the same instant 500 ms later — re-tripping the limit and producing a sawtooth that never clears. With full jitter, each tab waits a random duration in [0, 500ms], so the retries smear across the half-second and the limit drains smoothly. Cap the delay at 30 s and attempts at 5, and a request that can’t succeed gives up after roughly 0.5+1+2+4+8 ≈ 15 s of (jittered) waiting instead of hammering forever.

Comparison: jitter variants for the browser

Variant Delay for attempt n Browser fit Note
Full jitter random(0, min(cap, base·2ⁿ)) Best default Widest spread; minimal collisions across tabs
Equal jitter d/2 + random(0, d/2), d = min(cap, base·2ⁿ) Good Guarantees ≥ half the computed wait
Decorrelated min(cap, random(base, prev·3)) Background sync Needs prev state; smoother ramp
No jitter min(cap, base·2ⁿ) Avoid Synchronizes tabs — the herd problem

For interactive browser code with many concurrent tabs, full jitter wins: it’s stateless, one line, and disperses the fleet best.

Retry loop state machine with full jitter and give-up A loop that sends a request, returns on success, and on 429 either gives up after max attempts or waits a full-jitter delay before retrying. send fetch 429 or 503? status check resolve OK attempt < max? budget left? wait jitter then loop give-up UX no yes yes no

Step-by-step implementation

  • Decide which statuses are retryable (429, 503) and which are terminal (4xx
  • Compute the base delay as base · 2ⁿ, then take min(cap, …)
  • Prefer a parsed Retry-After
  • Apply full jitter: random(0, chosen)
  • Pass an AbortSignal into every fetch
  • Stop at maxAttempts
// backoff-fetch.ts — full-jitter exponential backoff over fetch.
import { parseRetryAfter } from "./parse-retry-after"; // both-format parser

export interface BackoffOpts {
  maxAttempts?: number;   // total tries including the first (default 5)
  baseMs?: number;        // initial backoff (default 500)
  capMs?: number;         // ceiling per delay (default 30_000)
  signal?: AbortSignal;   // caller-supplied cancellation
  onRetry?: (info: { attempt: number; delayMs: number }) => void; // drive UX
}

const RETRYABLE = new Set([429, 503]);

// Promise-based sleep that rejects immediately if the caller aborts.
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
    const id = setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => {
      clearTimeout(id);
      reject(new DOMException("Aborted", "AbortError"));
    }, { once: true });
  });
}

export async function backoffFetch(
  input: RequestInfo, init: RequestInit = {}, opts: BackoffOpts = {},
): Promise<Response> {
  const max = opts.maxAttempts ?? 5;
  const base = opts.baseMs ?? 500;
  const cap = opts.capMs ?? 30_000;

  for (let attempt = 0; attempt < max; attempt++) {
    // Forward the caller's signal so navigation/unmount cancels the in-flight request.
    const res = await fetch(input, { ...init, signal: opts.signal });
    if (!RETRYABLE.has(res.status)) return res; // success or terminal error: done

    if (attempt === max - 1) return res; // out of budget — return the last 429

    const headerMs = parseRetryAfter(res.headers.get("retry-after"));
    const computed = Math.min(cap, base * 2 ** attempt);
    const chosen = headerMs ?? computed;        // server's answer beats our guess
    const delayMs = Math.random() * Math.min(cap, chosen); // FULL JITTER

    opts.onRetry?.({ attempt: attempt + 1, delayMs }); // UI: countdown + disable
    await sleep(delayMs, opts.signal);
  }
  // Unreachable: the loop returns the final response above.
  throw new Error("backoffFetch: exhausted");
}

Wiring the give-up UX is the other half. Disable the control while retrying, show the countdown from onRetry, and toast once on exhaustion:

const controller = new AbortController();
submitBtn.disabled = true;
try {
  const res = await backoffFetch("/api/pay", { method: "POST", body, signal: controller.signal }, {
    onRetry: ({ attempt, delayMs }) =>
      setStatus(`Rate limited — retrying (#${attempt}) in ${Math.ceil(delayMs / 1000)}s…`),
  });
  if (res.status === 429) toast("Too many requests. Please try again shortly.");
  else handle(res);
} catch (e) {
  if ((e as DOMException).name !== "AbortError") toast("Network error.");
} finally {
  submitBtn.disabled = false; // re-enable whether we succeeded, gave up, or aborted
}

Gotchas & edge cases

  • Only retry idempotent requests. A non-idempotent POST that may have partially succeeded must not be blindly replayed; gate retries behind an idempotency key or restrict to safe methods.
  • AbortError is not a failure to toast. When the user navigates away you abort on purpose — swallow AbortError rather than showing an error.
  • Clear timers on abort. A setTimeout left running after unmount leaks and may fire into a dead component. The sleep above removes it on abort.
  • Cap and attempts. A cap alone still loops forever; a max-attempt count alone can still wait days per attempt. You need both.
  • Math.random() is fine here. Jitter doesn’t need crypto-grade randomness; Math.random() de-correlates clients adequately.

Verification & testing

Stub fetch to return 429 a fixed number of times, inject a deterministic clock, and assert attempt count and give-up:

let calls = 0;
globalThis.fetch = async () => {
  calls++;
  return new Response("", { status: calls <= 2 ? 429 : 200 }); // fail twice, then OK
};
const res = await backoffFetch("/x", {}, { baseMs: 1, capMs: 5, maxAttempts: 5 });
console.assert(res.status === 200 && calls === 3, "recovered after two 429s");

Then confirm dispersion under real concurrency with a load tool and watch that retries spread, not spike:

# Fire 200 concurrent clients at a limited endpoint; a jittered client fleet
# should show a smooth retry distribution, not synchronized 429 waves.
hey -n 2000 -c 200 -m POST https://api.example.com/v1/pay

Frequently Asked Questions

Full jitter or equal jitter for browser code?

Full jitter for interactive pages with many tabs — it disperses the fleet best and is stateless. Choose equal jitter only when a too-short retry would be harmful and you want to guarantee at least half the computed wait actually elapses.

Should backoff ignore Retry-After entirely?

No — honor it. The server knows when its window resets; your computed delay is only a fallback for when the header is absent. Still apply jitter and the cap on top of the parsed value so a shared reset time doesn't release every client at once.

How do I cancel an in-flight backoff when the user leaves?

Thread an AbortController signal through both fetch and the sleep timer. On abort, clear the pending setTimeout and reject with AbortError, which the caller swallows rather than treating as a real failure.

What's a sane default for max attempts and cap?

For interactive flows, 4–5 attempts with a 30 s cap keeps total worst-case wait under ~30–45 s before giving up — long enough to ride out a brief limit, short enough that the user isn't stranded. Background sync can use more attempts and a higher cap.