Home/Learn/Full-Stack Integration/Timeouts, Retries and Idempotency

Timeouts, Retries and Idempotency

Advanced
Calling Your API

A 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.

A timeout budget, decreasing inward
// 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.

Backoff with jitter; never retry a 4xx
// 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.

One key per operation, reused across attempts
// 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 Aria
1

Why is retrying a POST dangerous, and how is it made safe?

Hard
2

Why does exponential backoff need jitter?

Medium
3

How should timeouts be chosen across a browser, an API and a database?

Hard

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.

Loading discussion…