AbortController to cancel a fetch cleanly after a timeout
Published July 24, 2026
export const fetchWithTimeout = async (url: string, timeoutMs: number) => {
const signal = AbortSignal.timeout(timeoutMs);
try {
const response = await fetch(url, { signal });
return await response.text();
} catch (err) {
const timedOut = err instanceof Error && err.name === "TimeoutError";
throw timedOut ? new Error(`Request to ${url} timed out after ${timeoutMs}ms`) : err;
}
};What
AbortSignal.timeout(ms) creates a signal that fires on its own after ms milliseconds, no setTimeout/clearTimeout bookkeeping required. Pass it to fetch's signal option and the request aborts itself if it runs too long.
Why it matters
The classic manual version — setTimeout(() => controller.abort(), ms) — works, but you own the timer's whole lifecycle: you must clear it on success or it leaks, and you must remember to build the controller in the first place. It also collapses every abort reason into the same generic AbortError, so a request you cancelled on purpose looks identical to one that timed out.
AbortSignal.timeout() fixes both. Node creates and manages the timer internally, and when it fires, the resulting DOMException has name set to "TimeoutError" specifically — not the generic "AbortError" you'd get from calling controller.abort() yourself. That distinction is what lets you retry a timeout while still surfacing a manual cancellation immediately.
How it works
AbortSignal.timeout(timeoutMs)returns a signal already wired to fire aftertimeoutMs— no controller needed since there's nothing to abort manually.fetch(url, { signal })ties the request to that signal; Node'sfetch(built on undici) rejects the promise the moment the signal aborts, mid-flight or not.- The
catchblock checkserr.name === "TimeoutError"to rewrap the error with the URL and duration baked in, so whoever's logging it doesn't have to guess which request timed out. - Any other rejection (DNS failure, connection reset, a manual abort elsewhere) is rethrown unchanged.
Gotchas
AbortSignal.timeout()requires Node 17.3+/16.14+ — safe on any current LTS, but worth knowing if you're targeting something older.- The timer keeps running even after the fetch settles on its own; it's cheap and self-cleans, but don't assume you need to cancel it — there's nothing exposed to cancel.
- If you need both a timeout and a manual "cancel this on unmount" path, combine signals with
AbortSignal.any([manualSignal, AbortSignal.timeout(ms)])rather than juggling two separate try/catch branches.
Related
AbortSignal.any()— merges multiple abort sources into one signal; the natural next step once you need manual cancellation alongside a timeout.events.once(emitter, "abort", { signal })— waiting on a signal's abort event directly, instead of passing the signal into an API that already understands it.