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:
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:
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:
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:
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:
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));
}
}
}
// 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 Requestor404 Not Foundwill fail identically on every retry; there's nothing transient about it, and retrying just wastes time and requests. - A non-idempotent
POSTwith 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
AbortControlleris 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
POSTthat 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
setTimeouthandle once a request settles — a small leak that adds up across many requests in a long-lived page.