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.

M. Calder8 min read

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:

TypeScript
function backoff(attempt: number, baseMs = 200): number {
  return Math.min(baseMs * 2 ** attempt, 5_000);
}
// attempt 0 -> 200ms, 1 -> 400ms, 2 -> 800ms, capped at 5s

Backoff 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:

TypeScript
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:

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

Related briefs