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 readModern 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:
| Failure | Retry? |
|---|---|
| Network error or timeout | Yes, if the operation is idempotent |
429 Too Many Requests | Yes, after the Retry-After delay |
500, 502, 503, 504 | Yes, with backoff |
400, 401, 403, 404, 422 | No: 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.