fetch() With a Deadline: AbortSignal.timeout and Friends
fetch has no timeout. None. A hung server means your await hangs with it - for minutes, until the OS-level connection gives up. Every production fetch call needs a deadline, and the modern API makes it one line:
const res = await fetch('/api/report', { signal: AbortSignal.timeout(8000) });
AbortSignal.timeout(ms) builds a pre-armed signal - no AbortController ceremony, no setTimeout to clean up, and unlike the old Promise.race trick, the request is actually cancelled, freeing the connection.
Handling the failure properly
try {
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (err.name === 'TimeoutError') return showRetry('Server took too long.');
if (err.name === 'AbortError') return; // we cancelled on purpose - silence
throw err; // real network/HTTP errors
}
Three distinct outcomes, three names. Timeouts get user-facing retry UI, deliberate aborts get ignored, everything else escalates. Collapsing these into one generic catch is how "user typed too fast" ends up in your error tracker as a thousand phantom failures.
The autocomplete pattern: cancel stale requests
let controller;
input.addEventListener('input', async () => {
controller?.abort(); // kill the previous in-flight search
controller = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(input.value)}`, {
signal: controller.signal,
});
render(await res.json());
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
});
Without cancellation, five keystrokes launch five racing requests, and whichever lands last paints the UI - often results for an old query. Aborting the previous request makes the newest query the only query. (The full end-to-end version with debouncing and the PHP side is its own tutorial.)
Combining deadlines with cancellation: AbortSignal.any
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]);
The request dies when either the user cancels or the deadline hits - the pattern for search-with-timeout, component unmount + deadline, and similar. One signal object also cleans up event listeners tied to the same lifecycle (that trick here).
Support: AbortSignal.timeout since Chrome 103/Firefox 100/Safari 16; .any since Chrome 116/Firefox 124/Safari 17.4. For anything older, three lines recreate timeout() with a controller and setTimeout. However you build the signal: no fetch without one, ever. Networks fail slowly; UIs shouldn't.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.