Tutorial fetch assumes a happy network. Production networks time out, flake and race โ three patterns handle all of it.
1. Timeout with AbortController
async function fetchWithTimeout(url, ms = 8000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}fetch has NO default timeout โ a hung request hangs forever without this.
2. Retry with exponential backoff
async function fetchRetry(url, tries = 3) {
for (let i = 0; i < tries; i++) {
try { return await fetchWithTimeout(url); }
catch (err) {
if (i === tries - 1) throw err;
await new Promise(r => setTimeout(r, 500 * 2 ** i)); // 0.5s, 1s, 2s
}
}
}Retry 5xx/network errors only โ retrying a 400 resends a bad request; retrying a POST can double-charge.
3. Cancel stale searches
let ctrl;
input.addEventListener("input", async () => {
ctrl?.abort(); // kill the previous request
ctrl = new AbortController();
try {
const res = await fetch(`/api/search?q=${input.value}`, { signal: ctrl.signal });
render(await res.json());
} catch (e) {
if (e.name !== "AbortError") throw e; // aborts are expected
}
});Fast typing can return responses out of order โ cancellation guarantees only the latest renders. These three functions belong in every project's utils file.