Exponential Backoff & UX
When a client keeps hitting 429, the question is no longer how long does one retry wait but how does the whole retry curve behave under load — and getting that curve wrong is the fastest way to turn a brief limit breach into a self-inflicted outage. This guide sits in the frontend resilience and UX handling area and covers two halves that are usually treated separately and shouldn’t be: the math of exponential backoff with jitter, and the UX layer (disabled buttons, countdowns, toasts, optimistic queues) that makes the wait tolerable for the human watching the spinner.
The core principle: retries must spread out exponentially and randomly, and a parsed Retry-After always wins over a number your client invented.
Mechanism: growth, jitter, and caps
Exponential backoff computes the delay for attempt n as base × 2ⁿ, capped at a ceiling. The cap stops the delay from exploding (attempt 20 of an uncapped base=1000 is over 12 days). But pure exponential backoff has a fatal flaw at scale: every client that failed at the same instant retries at the same computed time, producing synchronized retry waves — the thundering herd. Jitter breaks that synchronization by randomizing each client’s delay.
The state per retry loop is small:
attempt— current attempt index (0-based).base— initial delay, e.g. 250–1000 ms.cap— maximum single delay, e.g. 30 000 ms.prev— previous delay (only for decorrelated jitter).
The exponential term itself is O(1) to compute; the value to internalize is that the expected delay grows geometrically while the variance from jitter keeps any two clients from colliding.
Decision table: jitter strategies
All four approaches below are correct; they trade off variance, implementation effort, and worst-case clustering. The canonical reference is AWS’s “Exponential Backoff And Jitter.”
| Strategy | Formula (delay for attempt n) | Spread | Worst-case clustering | Use when |
|---|---|---|---|---|
| No jitter | min(cap, base·2ⁿ) |
None | Severe — all clients align | Never at scale; single-client scripts only |
| Full jitter | random(0, min(cap, base·2ⁿ)) |
Widest | Lowest | Default for browser fleets; best herd dispersion |
| Equal jitter | half + random(0, half), half = min(cap, base·2ⁿ)/2 |
Medium | Low | When you want a guaranteed minimum wait |
| Decorrelated | min(cap, random(base, prev·3)) |
Wide, self-feeding | Low | Long-running background sync; smooths over many attempts |
Selection rules:
- Reach for full jitter by default — it disperses a large browser fleet best and is one line of code.
- Use equal jitter when a too-short retry is harmful (e.g. you want at least half the computed delay to actually elapse).
- Use decorrelated jitter for long-lived background pollers where the delay should ratchet up smoothly across many attempts.
- A detailed full-jitter
fetchimplementation, includingAbortControllerand give-up UX, lives on exponential backoff with jitter in the browser.
Honoring Retry-After over computed delay
Computed backoff is a guess; Retry-After is the server’s answer. The rule is simple: if the response carries a usable Retry-After, wait that long (still jittered, still capped); only when it is absent do you fall back to the exponential formula. The parsing of that header — both wire forms, clamping, and the RateLimit-Reset fallback — is covered in Retry-After parsing.
// One step of the retry loop: server's answer wins, computed delay is fallback.
function nextDelayMs(res: Response, attempt: number, base = 500, cap = 30_000): number {
const headerMs = parseRetryAfter(res.headers.get("retry-after")); // server's answer
const computed = Math.min(cap, base * 2 ** attempt); // our guess
const chosen = headerMs ?? computed;
return Math.random() * Math.min(cap, chosen); // full jitter on whichever we used
}
The UX layer
Backoff math protects the backend; the UX layer protects the user’s trust. While a retry is pending you should:
- Disable the triggering control so the user can’t pile on more requests behind the limit.
- Show a countdown (“retrying in 4s…”) sourced from the same delay value, so the wait is legible rather than a frozen spinner.
- Toast on give-up, not on every retry — surface a single, actionable “Too many requests, try again shortly” once attempts are exhausted.
- Queue optimistically where the action is idempotent: accept the user’s input into a client-side queue, drain it as the limit recovers, and reconcile. This is the pattern behind retry queues in Axios interceptors.
| UX signal | When | Sourced from |
|---|---|---|
| Disabled submit button | While any retry is in flight | retry-loop active flag |
| Countdown timer | During each backoff wait | the chosen delay (nextDelayMs) |
| Inline “retrying…” status | Attempts 1…max | attempt counter |
| Error toast | After max attempts exhausted | give-up event |
| Optimistic queue badge | Idempotent writes under limit | client queue depth |
Failure modes & mitigations
- Synchronized retries (thundering herd). Pure exponential backoff without jitter re-trips the limit in waves. Always jitter.
- Unbounded growth. Forgetting the cap lets delays reach days. Cap every branch.
- Ignoring
Retry-After. Retrying before the server’s stated window wastes the attempt and may extend the penalty. Honor it. - Infinite retries. A request that will never succeed (a
400, an auth failure) must not loop. Retry only idempotent requests on429/503, and cap attempts. - Frozen UI. A long backoff with no countdown reads as a hang. Drive a visible timer from the same delay.
Child topics
- Exponential Backoff With Jitter in the Browser — a full-jitter
fetchimplementation withAbortController, max attempts, Retry-After precedence, and give-up UX.
Related
- Frontend Resilience & UX Handling — the parent area for client-side rate-limit behavior.
- Retry-After Parsing — turning the server’s header into the delay you jitter.
- Retry Queue Implementation — draining queued requests as the limit recovers.
- Client-Side Rate-Limit State — throttling proactively so backoff fires less often.