fetch() has no timeout option, so by default a request can hang for as long as the OS lets the socket stay open. The modern fix is two lines:
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const data = await res.json();AbortSignal.timeout(5000) returns a signal that aborts itself after 5000 milliseconds. Pass it as signal and the fetch is cancelled the moment the timer fires. No manual setTimeout, no flag to track, no cleanup. That is the whole answer for the common case. The rest of this page is the detail: cancelling by hand, combining a timeout with a user cancel, telling a timeout apart from a real network error, and the one thing abort does not do.
Why the old setTimeout race did not work
For years the standard advice was to race the fetch against a timer:
// DON'T: this does not cancel anything
function te_fetchTimeout(url, ms) {
return Promise.race([
fetch(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("timeout")), ms)
),
]);
}This rejects after ms, so your code moves on. But the fetch itself is still running. The connection stays open, the bytes keep arriving, and any work the request kicked off keeps going. You did not add a timeout; you added a way to ignore a request that is still consuming a socket. Promises do not model cancellation, which is exactly why AbortController exists as a separate primitive. If you have this pattern in a codebase, it is the thing to replace.
Cancel by hand with AbortController
AbortSignal.timeout() is perfect when time is the only trigger. When you also need to cancel on a button click, a route change, or because a newer request superseded this one, reach for AbortController directly:
const controller = new AbortController();
// Cancel on demand:
cancelButton.addEventListener("click", () => controller.abort());
// And still time out:
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
return data;
} finally {
clearTimeout(timer);
}One controller, one signal, two ways to trip it. The clearTimeout in finally matters: once the fetch settles, you do not want a stray timer firing abort() on a controller nobody is listening to anymore. finally is the right place for that cleanup because it runs whether the request succeeded, failed, or was aborted.
Combine a timeout and a user cancel: AbortSignal.any
The hand-rolled controller above mixes two concerns into one object. Since 2023 there is a cleaner way to express "abort if either of these fires": AbortSignal.any(). It takes an array of signals and returns a single signal that aborts as soon as any one of them does.
function te_fetchWithCancel(url, userSignal, ms = 5000) {
const signal = AbortSignal.any([userSignal, AbortSignal.timeout(ms)]);
return fetch(url, { signal });
}
// caller keeps its own controller for the manual cancel:
const controller = new AbortController();
const data = await te_fetchWithCancel(url, controller.signal).then((r) => r.json());
// ...later: controller.abort();Now the timeout and the user cancel are independent, composable signals. You can pass userSignal down through several layers (a React component's cleanup signal, a request-scoped signal from a framework) and combine it with a fresh timeout at the call site, without anyone needing to know about the others.
Catch AbortError, but not at the cost of real errors
An aborted fetch rejects with a DOMException whose name is "AbortError". A timeout via AbortSignal.timeout() rejects with name === "TimeoutError". Everything else (DNS failure, connection refused, TLS error) is a genuine network error. Treat them differently:
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
if (e.name === "TimeoutError") {
// our 5s timeout fired
console.warn("Request timed out");
} else if (e.name === "AbortError") {
// someone called abort() (user cancel, unmount, etc.)
console.info("Request cancelled");
} else {
// real failure: network down, bad TLS, server unreachable
throw e;
}
}There is one cross-engine wrinkle worth knowing before you lean on TimeoutError. The spec says a fetch aborted by AbortSignal.timeout() should reject with TimeoutError, and Firefox and Node (undici) do exactly that. Chromium-based browsers (Chrome, Edge) have long surfaced the timeout as an AbortError with the message "The user aborted a request" instead, so a catch that only matches TimeoutError will fall through to the generic branch in Chrome. Because of that, the robust split is to check AbortError first (it covers both a manual cancel and a Chromium timeout) and treat TimeoutError as the more specific case where the engine reports it:
} catch (e) {
if (e.name === "TimeoutError" || e.name === "AbortError") {
// request did not finish: our timeout fired, or someone cancelled.
// Chromium reports a timeout as AbortError, so handle both here.
console.warn("Request timed out or was cancelled");
} else {
throw e; // real failure: network down, bad TLS, server unreachable
}
}If you genuinely need to separate "timed out, retry?" from "you cancelled this" and you cannot trust the name across engines, track your own intent: keep the timeout's AbortController and your user-cancel AbortController separate, and in the catch read which signal aborted (signal.aborted / signal.reason) rather than the error name. Note the if (!res.ok) throw line too: fetch does not reject on a 404 or 500, so a timeout wrapper alone will happily resolve a failed response. That status check belongs in every fetch (more on that in the fetch API guide).
The gotcha: abort is client-side
Aborting a fetch closes the connection from your side. Whether the server stops the work it already started is entirely up to the backend. If your fetch triggered a payment, a database write, or a long report generation, calling abort() does not roll any of that back. The request may have already reached the server and committed before the abort landed. Design accordingly: make the operation idempotent, or have the server honour a cancellation token of its own. The client-side abort is about not waiting and not leaking a socket, not about undoing server effects.
It works in Node too
This is not browser-only. Node ships a global fetch and AbortSignal.timeout() since Node 17.3 (and they are stable from Node 18), so the exact same two-line timeout works server-side with no node-fetch dependency:
// Node 18+, no imports needed
const res = await fetch("https://api.example.com/data", {
signal: AbortSignal.timeout(5000),
});That makes the pattern portable: the same fetch-with-timeout helper runs in a browser bundle and in a Node API route or script without change. If you are pairing this with retry-and-poll logic, see sleep, wait, and poll in JavaScript for the delay side of the loop, and the promises guide for handling the rejections cleanly.
Browser and Node support
| Feature | Browser | Node.js | Notes |
|---|---|---|---|
AbortController / signal | Chrome 66 | Node 14.17 | The base primitive; widely available. |
AbortSignal.timeout() | Chrome 124 | Node 17.3 (backported to 16.14), stable in 18 | The one-line timeout. The static method landed in Chrome 103, but MDN counts full support from 124 (Chromium did not surface TimeoutError correctly with fetch before then; see below). |
AbortSignal.any() | Chrome 116 | Node 20.3 (2023) | Combine multiple signals. |
AbortController and the signal option are universal now. AbortSignal.timeout() is safe to use everywhere current; if you must support a runtime older than Chrome 103 / Node 17.3, fall back to the manual AbortController + setTimeout form above (it goes back to Chrome 66 / Node 14.17). AbortSignal.any() is the newest of the three, so guard it with a typeof AbortSignal.any === "function" check if you target older environments.
FAQ
See also
- The fetch() API: a practical guide: the request/response model, the
res.okgotcha, and why fetch does not reject on a 404. - JavaScript promises explained: the flagship on promise states,
async/await, and handling rejections, which is what an aborted fetch is. - Sleep, wait, and poll in JavaScript: the delay and polling side, for retry loops that pair with a fetch timeout.
Sources
Authoritative references this article was fact-checked against.
- AbortSignal: timeout() static method (MDN)developer.mozilla.org
- AbortSignal: any() static method (MDN)developer.mozilla.org
- AbortController (MDN)developer.mozilla.org
- AbortSignal (MDN)developer.mozilla.org
- Global objects: fetch, AbortController, AbortSignal (Node.js docs)nodejs.org
- AbortSignal.timeout() browser support (caniuse)caniuse.com





