Developer Productivity
Designing Resilient Retry Logic for APIs
Naive retries turn a slow dependency into a self-inflicted outage. The difference is backoff, jitter, and knowing which errors are worth retrying at all.

Retry logic looks trivial: the request failed, try again. Done badly, it is one of the most reliable ways to turn a transient blip into a cascading failure. Done well, it is invisible — failures recover and nobody notices. The difference comes down to three decisions.
Decision one: which errors are retryable
Not every failure deserves a retry. Retrying a 400 Bad Request is pure waste — the request is wrong, and it will be wrong in 200ms too. As a rule:
- Retry: network timeouts, connection resets, 429 Too Many Requests, 5xx server errors.
- Do not retry: 4xx client errors (except 429), malformed requests, authentication failures.
If you retry indiscriminately you amplify load on an already-struggling dependency and you delay surfacing real bugs.
Decision two: backoff, not fixed delays
Fixed-delay retries are synchronized. Every client that hit a 503 at the same moment retries at the same moment, hammering the recovering service in lockstep. The fix is exponential backoff:
function backoff(attempt: number, baseMs = 200): number {
return Math.min(baseMs * 2 ** attempt, 5_000);
}
// attempt 0 -> 200ms, 1 -> 400ms, 2 -> 800ms, capped at 5sBackoff spreads load over time, giving the dependency room to recover.
Decision three: jitter
Even with backoff, clients that started together stay roughly synchronized. Jitter breaks the synchrony by adding randomness:
function backoffWithJitter(attempt: number, baseMs = 200): number {
const capped = Math.min(baseMs * 2 ** attempt, 5_000);
return Math.random() * capped; // "full jitter"
}Full jitter — choosing a random delay uniformly between 0 and the capped backoff — is simple and effective. It guarantees no two clients retry at the same instant, which is the whole goal.
A minimal, correct retry
Putting it together:
async function fetchWithRetry(
url: string,
opts: RequestInit,
maxAttempts = 4,
): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const res = await fetch(url, opts);
if (res.status === 429 || res.status >= 500) {
throw new Error(`Retryable status ${res.status}`);
}
return res;
} catch (e) {
lastError = e;
if (attempt < maxAttempts - 1) {
await new Promise(r => setTimeout(r, Math.random() * Math.min(200 * 2 ** attempt, 5_000)));
}
}
}
throw lastError;
}Four attempts with full jitter and a 5s cap recovers most transient failures without endangering the dependency.
The part teams forget: a circuit breaker
Retries alone cannot save you if a dependency is fully down — you will dutifully retry four times per request, multiplying your own load by four while accomplishing nothing. A circuit breaker watches failure rate and, past a threshold, stops calling the dependency entirely for a cool-down period. Retries handle transient failures; the breaker handles sustained ones. You need both.
Takeaways
- Retry only genuinely transient errors; never 4xx.
- Exponential backoff spreads load; jitter breaks synchrony.
- Cap attempts and delays — retries must be bounded.
- Pair retries with a circuit breaker for sustained outages.
Resilient retry logic is mostly restraint: retrying less, more intelligently, and knowing when to stop.