Retries, Backoff, and Idempotency (from the Frontend)
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.
Which methods are safe to retry?
| 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 HTTP semantics and caching.
Which status codes should you retry?
| 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.
Exponential backoff with jitter — show the math.
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);
// full jitter
return Math.random() * exp;
}
// 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.
Implement a retry wrapper for fetch.
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.
How do idempotency keys work, from the client’s side?
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) {
// generated ONCE per attempt
const key = crypto.randomUUID();
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: Worked Design — Idempotent Payment Flow.
Does TanStack Query retry by default?
Yes — queries retry 3 times with exponential backoff by default; mutations don’t retry. Configurable per query or globally:
useQuery({
queryKey: [...],
queryFn: ...,
// or false, or a function
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
});
useMutation({
mutationFn: ...,
// default — don't retry, may cause duplicates
retry: false,
});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.
What’s a circuit breaker on the client?
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.
How do timeouts interact with retries?
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
- 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.
Related
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
Interview angle 5
- “Which requests are safe to retry?” - the idempotent methods: GET, PUT, DELETE. A POST or PATCH needs an idempotency key, and the key must be generated once per logical operation - generating it inside the retry loop means every attempt looks like a new operation, which is the bug the key existed to prevent.
- “Which responses do you retry?” - 5xx, 408, 429 and network errors. Not 4xx: the request is wrong, so the same request fails the same way and you have added load to an error.
- “What backoff do you use?” - exponential with full jitter, and honour
Retry-Afteron a 429 because the server is telling you its capacity. Without jitter every client that failed together retries together, which is a self-inflicted denial of service on your own recovery. - “How do you bound the whole thing?” - a per-attempt timeout and an
overall budget, so retries cannot outlive the caller’s patience. Compose them
with
AbortSignal.any([AbortSignal.timeout(perAttempt), overall.signal]). - “Why does TanStack Query retry queries but not mutations?” - because a query is a read and safe to repeat, while a mutation may have applied before the response was lost. The asymmetry is correct, and turning on mutation retries without an idempotency key is how you get duplicate records.