Retries, Backoff, and Idempotency (from the Frontend)
TL;DR
A retry policy without an idempotency contract is a duplicate-data bug. Always retry GET / PUT / DELETE on 5xx and network errors with exponential backoff + jitter; never retry POST / PATCH unless the server accepts an idempotency key. Plus: retry only the right errors (5xx + connection failure + 408/429), not 4xx. The pattern below is the universal front/back contract — same shape as the backend resilience patterns, just enforced at the call site.
Interview Q&A
Q: Which methods are safe to retry?
A:
| Method | Idempotent? | Retry on network/5xx? |
|---|---|---|
| GET | yes | yes, freely |
| HEAD | yes | yes |
| PUT | yes | yes |
| DELETE | yes | yes |
| POST | no | only with an idempotency key |
| PATCH | usually no | only with an idempotency key |
The “idempotent = N calls have the same effect as one” rule is what makes retry safe. Backend deep-dive in ../../backend/12_protocols/http/03_http_semantics_and_caching.md.
Q: Which status codes should you retry?
A:
| Code | Retry? |
|---|---|
| 5xx (500, 502, 503, 504) | yes — server’s fault |
| 408 (request timeout) | yes — “try again later” |
| 429 (rate limited) | yes — honor Retry-After, don’t just back off |
| 4xx (other) | no — your request is wrong; retrying makes it worse |
| Network error / DNS failure / abort timeout | yes — connection problem |
A retry policy that retries 401 will infinitely spin against an expired token; one that retries 422 will spam the server with the same bad input.
Q: Exponential backoff with jitter — show the math.
A:
function backoff(attempt: number): number {
const base = 250; // 250ms base
const cap = 30_000; // cap at 30s
const exp = Math.min(cap, base * 2 ** attempt);
return Math.random() * exp; // full jitter
}
// attempt 0 → 0–250ms
// attempt 1 → 0–500ms
// attempt 2 → 0–1000ms
// attempt 3 → 0–2000ms
// attempt 4 → 0–4000ms
// ...
Why jitter: without it, all clients that hit a 503 retry simultaneously — and continue to retry simultaneously — turning a transient outage into a sustained DDoS-on-yourself. Full jitter (random * exp) breaks the thundering herd.
Q: Implement a retry wrapper for fetch.
A:
type RetryOpts = { maxAttempts?: number; signal?: AbortSignal };
async function fetchWithRetry(
input: RequestInfo, init: RequestInit = {}, opts: RetryOpts = {}
): Promise<Response> {
const { maxAttempts = 3, signal } = opts;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
try {
const res = await fetch(input, { ...init, signal });
if (res.ok) return res;
// 5xx / 408 / 429 — retry; honor Retry-After on 429
if (res.status >= 500 || res.status === 408 || res.status === 429) {
const wait = res.status === 429 && res.headers.get("Retry-After")
? Number(res.headers.get("Retry-After")) * 1000
: backoff(attempt);
await sleep(wait, signal);
continue;
}
// 4xx (other) — don't retry
return res;
} catch (e) {
if ((e as Error).name === "AbortError") throw e;
lastError = e;
await sleep(backoff(attempt), signal);
}
}
throw lastError ?? new Error("fetchWithRetry: max attempts");
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new DOMException("aborted", "AbortError")); });
});
}
Important pieces: respects AbortSignal, special-cases 429 with Retry-After, doesn’t retry 4xx, caps attempts.
Q: How do idempotency keys work, from the client’s side?
A: The client generates a UUID per logical attempt and sends it in a header (Idempotency-Key). On retry of the same logical request, the client sends the same key — the server recognizes it and returns the original response instead of re-applying the side effect.
async function createOrder(body: OrderInput) {
const key = crypto.randomUUID(); // generated ONCE per attempt
return fetchWithRetry("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(body),
});
}
Critical rule: the key is generated once for the operation, not per retry. If you generate a new UUID on each retry, you’ve defeated the purpose — the server treats each retry as a new request.
For full server side, see the worked design: ../../system_design/07_worked_designs/08_idempotent_payments.md.
Q: Does TanStack Query retry by default?
A: Yes — queries retry 3 times with exponential backoff by default; mutations don’t retry. Configurable per query or globally:
useQuery({
queryKey: [...],
queryFn: ...,
retry: 3, // or false, or a function
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
});
useMutation({
mutationFn: ...,
retry: false, // default — don't retry, may cause duplicates
});
The “mutations don’t retry” default is correct — without an idempotency key, retrying a mutation risks duplication. Override per-mutation when you know it’s safe (PUT, DELETE) or you have an idempotency key.
Q: What’s a circuit breaker on the client?
A: After N consecutive failures to an endpoint, stop retrying entirely for a cooldown — surface “service unavailable” to the user instead of spinning. Less common on the client (services usually circuit-break centrally), but useful for offline-first apps and slow background sync.
Q: How do timeouts interact with retries?
A: Each attempt needs its own timeout (e.g., 10s), and the whole operation may have a total budget (e.g., 30s). Without per-attempt timeouts a single slow attempt eats the whole budget.
const overallController = new AbortController();
const overallTimer = setTimeout(() => overallController.abort(), 30_000);
await fetchWithRetry(url, {
signal: AbortSignal.any([overallController.signal, AbortSignal.timeout(10_000)]),
}, { maxAttempts: 3 });
clearTimeout(overallTimer);
Gotchas / edge cases
- Generating a new idempotency key per retry = duplicate side effects. The whole point is the same key.
- Retrying 429 without honoring
Retry-After— you back off less than the server told you to, contributing to the rate-limit problem. - Retrying 401 — token’s expired; refresh and retry once, don’t loop. A dedicated auth-refresh layer is cleaner than retry-on-401.
- Exponential backoff without jitter — synchronized clients = self-DDoS.
- No cap —
2 ** 10is 17 minutes; cap to a sane max (30s typical). - Retrying a POST with no idempotency contract — silent duplication. Either make the endpoint idempotent or do not retry POSTs.
- Race with abort — user navigates away mid-retry; the in-flight
fetchand thesleepshould both respect the signal.
What a senior is expected to say
- “Idempotent methods retry safely; POST/PATCH retry only with an idempotency key generated once per logical operation, not per attempt.”
- “Retry 5xx, 408, 429, and network errors. Don’t retry 4xx — the request is wrong; retrying makes it worse.”
- “Honor
Retry-Afteron 429. Exponential backoff with full jitter is the default — synchronized retries are self-inflicted DDoS.” - “Per-attempt timeout and overall budget. Compose
AbortSignals with.any().” - “TanStack Query retries queries by default and not mutations — the asymmetry is correct.”
Cross-references
- HTTP method semantics: ../../backend/12_protocols/http/03_http_semantics_and_caching.md
- Idempotent payments (server side): ../../system_design/07_worked_designs/08_idempotent_payments.md
- Backend resilience patterns (mirror): ../../system_design/02_resilience/
- Aborting before retrying: 05_abort_and_race_conditions.md
Further reading
- AWS “Exponential Backoff and Jitter”: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
- Stripe Idempotency-Key documentation: https://stripe.com/docs/api/idempotent_requests
- MDN —
Retry-Afterheader: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After