typescript 133 lines · 3 tabs

Retrying Fetch With Exponential Backoff and Full Jitter in TypeScript

Shared by codesnips Jul 2026
3 tabs
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);

export function isRetryable(error: unknown, response?: Response): boolean {
  if (response) {
    return RETRYABLE_STATUS.has(response.status);
  }
  // fetch throws TypeError for network-level failures
  return error instanceof TypeError;
}

export function computeDelay(attempt: number, baseMs: number, maxMs: number): number {
  const exponential = Math.min(maxMs, baseMs * 2 ** attempt);
  return Math.random() * exponential; // full jitter
}

export function parseRetryAfter(response?: Response): number | undefined {
  const header = response?.headers.get('retry-after');
  if (!header) return undefined;

  const seconds = Number(header);
  if (!Number.isNaN(seconds)) {
    return seconds * 1000;
  }

  const date = Date.parse(header);
  if (!Number.isNaN(date)) {
    return Math.max(0, date - Date.now());
  }
  return undefined;
}

export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(new DOMException('Aborted', 'AbortError'));
    }, { once: true });
  });
}
3 files · typescript Explain with highlit

This snippet implements a resilient HTTP client that retries transient failures using exponential backoff with full jitter, a technique that spreads retries randomly over a growing window to avoid the thundering-herd problem where many clients retry in lockstep and re-overload a recovering service.

In backoff.ts, computeDelay calculates the base delay as baseMs * 2 ** attempt, clamps it with Math.min against maxMs, and then applies full jitter by multiplying against Math.random(). Full jitter is preferred over fixed or equal jitter because it maximizes the spread between clients, and the cap prevents delays from growing unbounded on high retry counts. isRetryable centralizes the retry policy: network errors (a thrown TypeError from fetch) and the status codes 408, 429, and 5xx are considered transient, while 4xx client errors are not, since replaying a malformed request will never succeed.

The parseRetryAfter helper honors the server's Retry-After header, which may be either a delta in seconds or an HTTP date. When present it overrides the computed backoff, because the server's own hint about when to come back is more accurate than a blind guess.

In retryFetch.ts, retryFetch wraps the native fetch in a loop bounded by maxRetries. Each attempt gets its own AbortController wired to a perRequestTimeoutMs via setTimeout, so a single hung request cannot stall the whole retry budget; the timer is always cleared in a finally block. A caller-supplied signal is respected too, so an outer cancellation aborts the in-flight attempt and stops further retries. On a retryable outcome the code sleeps for retryAfter ?? computeDelay(...) before looping, and on a non-retryable response it returns immediately. When the budget is exhausted the last error or a synthesized one is rethrown so callers see a real failure rather than a silent undefined.

A key caveat shown here: retries are only safe for idempotent operations, so retryFetch defaults to GET and the demo in usage.ts uses it for reads. Applying blind retries to non-idempotent POST requests risks duplicate side effects unless the endpoint enforces idempotency keys. The trade-off is added latency and load in exchange for surviving brief outages without failing the user.

Share this code

Here's the card — post it anywhere.

Retrying Fetch With Exponential Backoff and Full Jitter in TypeScript — share card
Link copied