frontend / apis data fetching / 06_retries_backoff_idempotency.md

Retries, Backoff, and Idempotency (from the Frontend)

6 min read source

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 cap2 ** 10 is 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 fetch and the sleep should 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-After on 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

Further reading