TFS Digital
All insights

Web APIs

Building Resilient Web APIs: Timeouts, Retries and Idempotency

Every network call will eventually fail. Designing for that from day one with timeouts, safe retries and idempotency keys keeps small blips from becoming outages.

Gurpreet Bhamra · QA & AI Solutions Lead

3 min read

Modern web apps are stitched together from APIs: payments, email, search, AI models and your own back end. Each one is a network call, and every network call will eventually be slow, drop a connection or return an error. Resilience is about making sure those moments stay small.

Always set a timeout

A request without a timeout can hang until the platform kills the whole function, tying up resources and leaving the user staring at a spinner. In modern runtimes, AbortSignal.timeout() makes this a one-liner:

const response = await fetch("https://api.example.com/orders", {
  signal: AbortSignal.timeout(5_000), // give up after 5 seconds
});

Pick timeouts from real latency data: comfortably above the normal p99, well below the point where users give up.

Retry only what's safe to retry

Retries turn brief glitches into non-events, but only for the right failures:

FailureRetry?
Network error or timeoutYes, if the operation is idempotent
429 Too Many RequestsYes, after the Retry-After delay
500, 502, 503, 504Yes, with backoff
400, 401, 403, 404, 422No: retrying won't change the answer

Use exponential backoff with jitter so a fleet of clients doesn't retry in lockstep and knock a recovering service straight back over:

async function withRetry<T>(operation: () => Promise<T>, attempts = 3): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await operation();
    } catch (error) {
      if (attempt >= attempts || !isRetryable(error)) throw error;
      const backoff = 2 ** attempt * 100; // 200ms, 400ms, 800ms...
      await new Promise((resolve) => setTimeout(resolve, backoff + Math.random() * backoff));
    }
  }
}

Make writes idempotent

The dangerous case is a timeout on a write: did the payment go through or not? Retrying blindly could charge the customer twice.

The fix is an idempotency key: a unique ID the client generates once per logical operation and sends with every attempt. The server stores the result against that key, and if it sees the same key again it returns the stored result instead of repeating the work.

const idempotencyKey = crypto.randomUUID(); // once per checkout, reused on retries

await withRetry(() =>
  fetch("/api/orders", {
    method: "POST",
    headers: { "Idempotency-Key": idempotencyKey },
    body: JSON.stringify(order),
    signal: AbortSignal.timeout(5_000),
  }),
);

Payment providers such as Stripe support this pattern natively; for your own endpoints, a unique database constraint on the key does most of the work.

Protect your own API too

Resilience runs in both directions. Validate every input at the boundary, return clear status codes and error messages, and rate-limit public endpoints so one noisy client can't exhaust your quotas or downstream budgets. A 429 with a Retry-After header tells well-behaved clients exactly when to come back.

Test the unhappy paths

None of this matters if it's never exercised. Stub dependencies to return timeouts, 503s and malformed responses in your integration and end-to-end tests, and check that the user sees a helpful message rather than a blank screen. The failure paths are where resilient APIs earn their keep, so they deserve the same coverage as the happy path.