Timeouts, Retries and Idempotency
AdvancedA request with no timeout can hang forever, a retry without idempotency can charge a card twice, and retrying in a loop turns a slow backend into a dead one.
Overview
Networks fail partially, which is the difficulty. A request that times out may have succeeded — the response was lost, not the work — so retrying a POST can create a second record or take a second payment. The three tools that make this safe are a timeout so failure is bounded, exponential backoff with jitter so retries do not synchronise into a stampede, and an idempotency key so the server can recognise a repeat and return the original result instead of doing it again.
Timeouts
Every outbound call needs one, at every layer.
// fetch has no default timeout. A hung connection waits forever.
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
// Combining a timeout with user cancellation
const ctrl = new AbortController()
const signal = AbortSignal.any([ctrl.signal, AbortSignal.timeout(10_000)])
# Server side, calling anything else
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) as c:
r = await c.get(url)
# And a database statement timeout, so one bad query cannot pin a
# connection for minutes
SET statement_timeout = '5s';
// Budget them downwards through the stack. If the browser waits 10s,
// the API must give up before that, and its database calls before
// that again — otherwise the user sees a timeout while work is still
// running and consuming resources.
// browser 10s > API 8s > outbound HTTP 5s > query 3s
// Pick the number from the p99, not from a round figure: a timeout
// below your real latency turns a slow endpoint into a broken one.What to Retry
Method and status decide it. Backoff and jitter decide whether it helps.
// Safe to retry: GET, HEAD, PUT, DELETE (idempotent by definition)
// Retry on: a network error, 408, 429, 502, 503, 504
// NEVER retry: 400, 401, 403, 404, 422 — the answer will not change
// POST: only with an idempotency key
async function retrying(fn, { attempts = 3 } = {}) {
for (let i = 0; i < attempts; i++) {
try { return await fn() }
catch (e) {
if (!isRetryable(e) || i === attempts - 1) throw e
const base = Math.min(1000 * 2 ** i, 8000)
await sleep(base + Math.random() * 300) // jitter
}
}
}
// Jitter matters more than it sounds: without it, every client that
// failed at the same moment retries at the same moment, and the
// backend that was recovering is knocked over again.
// Honour Retry-After when the server sends it — on a 429 it is not
// advice, it is the rate limiter telling you exactly when to return.
// And stop retrying a service that is clearly down. A circuit
// breaker — open after N consecutive failures, half-open after a
// cooldown — protects both sides. TanStack Query gives you the
// client half of this with retry and retryDelay options.Idempotency Keys
How a POST becomes safe to retry, which is what makes payments survivable.
// Client generates a key per logical operation — NOT per attempt
const key = crypto.randomUUID()
await api('/payments', {
method: 'POST',
headers: { 'Idempotency-Key': key },
body: JSON.stringify({ amount: 49900 }),
})
// Retrying reuses the same key. That is the entire mechanism.
# Server: first request does the work and stores the response;
# a repeat returns the stored one without re-executing.
@router.post("/payments")
async def create(body: PaymentIn, idempotency_key: str = Header(...)):
existing = await db.get_idempotent(idempotency_key)
if existing:
return existing.response # same result, no double charge
async with db.transaction():
# reserve the key inside the transaction, or two concurrent
# requests both see "no existing" and both charge
await db.reserve_idempotency(idempotency_key)
result = await charge(body)
await db.save_idempotent(idempotency_key, result)
return result
// Keys expire — 24 hours is typical.
// Also guard the same operation at the UI level: disable the button
// while the request is in flight. Defence in depth, because the key
// only helps if the client actually reuses it.Key Points to Remember
- 1fetch has no default timeout — set one, and budget timeouts decreasing from browser to database
- 2Retry network errors and 5xx, never a 4xx, and honour Retry-After on a 429
- 3Exponential backoff needs jitter, or clients synchronise and knock over a recovering backend
- 4A POST is only safe to retry with an idempotency key generated per operation and reused across attempts
- 5The server must reserve the idempotency key inside the transaction, or concurrent duplicates both execute
Interview Questions
Sign in to ask AriaWhy is retrying a POST dangerous, and how is it made safe?
Why does exponential backoff need jitter?
How should timeouts be chosen across a browser, an API and a database?
Ask Aria about Timeouts, Retries and Idempotency
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.