Error Handling and Retries

Timeouts with AbortController, idempotency, exponential backoff with jitter, and a complete retry wrapper.

Two layers of failure: network vs. HTTP

The previous page drew a line between a resolved fetch() promise (the request reached a server and got some response, even an error one) and a rejected one (the request never produced a usable response at all). That rejection case — a genuine network-level failure — actually covers several distinct situations worth telling apart, because the right response to each is different:

  • DNS/connection failure — the hostname doesn't resolve, or the server refuses the connection outright. Immediate, nothing to wait for.
  • CORS block — the request may have reached the server and even gotten a response, but the browser refuses to hand it to your JavaScript because the response's CORS headers don't permit it (covered on the next page in this track). fetch() still reports this as a rejected promise, indistinguishable from a pure network failure without checking the browser's console.
  • Timeout — the request is simply taking too long, and you decide to give up on waiting. Unlike the two cases above, fetch() has no built-in concept of this at all — left alone, an in-flight request waits as long as the underlying connection stays open, however long that is.

fetch() has no built-in timeout

This surprises a lot of people coming from other HTTP clients: there is no { timeout: 5000 } option. Timeouts are built by composing fetch() with AbortController, which cancels an in-flight request by triggering its signal:

Javascript
async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(timer); // don't leave a dangling timer once the request settles either way
  }
}

Modern runtimes (recent browsers and Node 18+) also expose a shortcut, AbortSignal.timeout(ms), which creates a signal that aborts itself after the given duration — useful when you don't also need to cancel the request for some other reason:

Javascript
const response = await fetch('/api/products', { signal: AbortSignal.timeout(8000) });

If you need both a timeout and the ability to cancel manually (e.g., a component unmounting), AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]) combines multiple signals into one — the request aborts as soon as either fires.

Telling a timeout apart from a manual cancellation

Both cases surface as the same kind of rejected promise — an error whose name is 'AbortError' — so if you need to distinguish "the user cancelled" from "we gave up waiting," you have to track which one happened yourself:

Javascript
try {
  const response = await fetchWithTimeout('/api/products', {}, 5000);
  const data = await response.json();
} catch (error) {
  if (error.name === 'AbortError') {
    console.error('Request timed out or was cancelled');
  } else {
    console.error('Network failure:', error.message);
  }
}

What's actually safe to retry

Before writing any retry logic, decide whether the request is safe to repeat. This comes down to idempotency — whether running the same request twice has the same effect as running it once:

Method Idempotent? Safe to retry blindly?
GET Yes Yes
PUT Yes (replaces a resource with the same value) Yes
DELETE Yes (deleting an already-deleted resource is a no-op) Yes
PATCH Depends on the operation Only if the change itself is idempotent (e.g., "set status to X", not "increment count by 1")
POST No, in general Not without an idempotency key (see below)

A POST that creates an order or charges a card is the dangerous case: if the request actually succeeded server-side but the response was lost (a dropped connection on the way back), a naive retry creates a second order or a second charge. The request genuinely happened once — it's the client's knowledge of that fact that got lost.

Exponential backoff, concretely

Retrying immediately, over and over, is a bad instinct twice over: it doesn't give a struggling server any time to recover, and if many clients are all failing and retrying at once, they tend to retry in near-lockstep, arriving as a synchronized burst that makes the underlying problem worse rather than better. Exponential backoff grows the delay between attempts, and jitter (a small random offset) spreads out otherwise-synchronized retries so they don't all land at the same instant:

Plaintext
attempt 1 fails -> wait ~500ms   (base delay)
attempt 2 fails -> wait ~1000ms  (base * 2^1)
attempt 3 fails -> wait ~2000ms  (base * 2^2)
attempt 4 fails -> wait ~4000ms  (base * 2^3, capped at some maximum)
Attempt Backoff formula (base=500ms) Delay without jitter Delay with jitter (+/- up to base)
1 500 * 2^0 500ms 500-1000ms
2 500 * 2^1 1000ms 1000-1500ms
3 500 * 2^2 2000ms 2000-2500ms
4 500 * 2^3, capped at 10000ms 4000ms 4000-4500ms

A complete retry wrapper

Putting timeout, backoff, jitter, and idempotency awareness together into something you'd actually use:

Javascript
async function fetchWithRetry(url, options = {}, {
  retries = 3,
  baseDelayMs = 500,
  maxDelayMs = 10000,
  timeoutMs = 8000,
  retryableStatuses = [408, 429, 500, 502, 503, 504],
} = {}) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const response = await fetch(url, { ...options, signal: controller.signal });
      clearTimeout(timer);

      if (response.ok || !retryableStatuses.includes(response.status)) {
        return response; // success, or a non-retryable error status — stop here either way
      }

      if (attempt === retries) return response; // out of retries, hand back the last response

      // Respect a Retry-After header if the server sent one (common on 429/503)
      const retryAfter = response.headers.get('Retry-After');
      const delay = retryAfter
        ? Number(retryAfter) * 1000
        : Math.min(baseDelayMs * 2 ** attempt, maxDelayMs) + Math.random() * baseDelayMs;

      await new Promise(resolve => setTimeout(resolve, delay));
    } catch (error) {
      clearTimeout(timer);
      if (attempt === retries) throw error; // network failure/timeout, and out of retries

      const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs) + Math.random() * baseDelayMs;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}
Javascript
// Safe to retry: a GET
const response = await fetchWithRetry('/api/products/42');

// Only retry a POST if the server supports an idempotency key to dedupe it server-side
const response2 = await fetchWithRetry('/api/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({ productId: 42, quantity: 1 }),
});

An idempotency key — a unique value the client generates once per logical operation and sends with every retry attempt of that same operation — lets the server recognize "I've already processed this exact key" and return the original result instead of performing the action a second time. It's the standard way payment APIs make a POST safely retryable.

What NOT to retry

  • 4xx client errors, other than 429 — a 400 Bad Request or 404 Not Found will fail identically on every retry; there's nothing transient about it, and retrying just wastes time and requests.
  • A non-idempotent POST with no idempotency key — retrying blindly risks a duplicate side effect that's often worse than the original failure.
  • Anything already past its useful deadline — if a user gave up and navigated away, or the data being fetched is now stale (a superseded search query), keep retrying nothing and let it die; this is exactly what AbortController is for.

Common mistakes

  • Retrying every failed request indiscriminately, including 4xx client errors that will never succeed no matter how many times they're repeated.
  • Retrying with no delay, or a fixed delay with no jitter, causing every failing client to hammer the server again at the same moment.
  • Blindly retrying a POST that creates or charges something, without an idempotency key, risking a duplicate side effect that's worse than the original failed request.
  • Forgetting to clear the timeout's setTimeout handle once a request settles — a small leak that adds up across many requests in a long-lived page.