fetch() with a timeout: AbortSignal.timeout(), AbortSignal.any() and the setTimeout trap
If you're used to axios, jQuery or the old request package, you'll look for a timeout option on fetch() and not find one. Instead, fetch accepts a signal, an AbortSignal that cancels the request when it fires. Today the signal you want is built in:
const res = await fetch('https://api.example.com/items', {
signal: AbortSignal.timeout(5000), // give up after 5 seconds
});That's the short answer. The rest of this post covers what that timeout actually includes, how to combine it with your own cancellation, and the bug in the recipe most older articles (this one included, before this rewrite) recommend. All of the code below was run against a small local test server on stock node:24-slim and node:26-slim images, and the output is pasted as is.
Don't use the setTimeout recipe
This is the pattern you'll find everywhere, including in the first version of this post:
const controller = new AbortController();
setTimeout(() => controller.abort(), 10000);
const res = await fetch(url, { signal: controller.signal });It does abort slow requests. But nothing ever clears the timer, so in Node.js it keeps the process alive until the timer fires, even after the request has finished. A script that fetches one fast URL and should exit at once:
$ node exit-old.mjs # setTimeout + AbortController
old pattern: got {"ok":true} at 36ms
old pattern: process exited after 10009ms
$ node exit-new.mjs # AbortSignal.timeout(10000)
AbortSignal.timeout: got {"ok":true} at 33ms
AbortSignal.timeout: process exited after 33msIn a CLI or a test suite that's ten seconds of hanging for nothing. On a server it's one live timer per request, each holding its AbortController until it fires. It also reports a timeout as an AbortError, so you can't tell a timeout from any other cancel. AbortSignal.timeout() fixes both problems. If you really must use setTimeout, clear it in a finally block.
What the timeout covers
The test server has two slow endpoints. /slow waits 3 seconds before sending anything. /drip sends its headers right away and then one byte every 500 ms for 10 seconds. That second case is important: await fetch() resolves as soon as the headers arrive, and the body is only read when you call res.json() or res.text().
The signal stays attached to the response, so the deadline covers the whole exchange: connecting, waiting for headers, and reading the body. A timeout while you're reading the body rejects the res.text() promise with the same TimeoutError.
That matters more than it sounds. Node's fetch (undici) has built-in timeouts of its own: 10 s to connect, 300 s to receive headers, and 300 s of silence between body chunks. A response that keeps trickling in never trips any of them. The drip endpoint with no signal at all just runs to the end:
$ node nodeadline.mjs
no deadline: 20 bytes after 10088msA server that sends a byte every few minutes would hold your request open indefinitely. Only a total deadline that you set yourself prevents that.
A small helper: timeout plus your own signal
Usually you want both: a deadline, and a way to cancel early, for example when the user leaves the page or clicks Cancel. AbortSignal.any() combines signals into one that fires when the first of them fires:
fetch-with-timeout.mjs:
/**
* fetch() with a total deadline that covers connecting, waiting for headers
* and reading the body. Pass your own `signal` too, to cancel it early.
*/
export function fetchWithTimeout(url, { timeout = 10_000, signal, ...init } = {}) {
const deadline = AbortSignal.timeout(timeout);
return fetch(url, {
...init,
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
});
}Since the signal that fired becomes the rejection reason, err.name tells you what happened:
TimeoutError: the deadline passed.AbortError: your own signal was aborted, i.e. the user cancelled.- Anything else: the request failed at the network level. In Node that's a
TypeError: fetch failed, with the real reason (ECONNREFUSED,ENOTFOUND, …) inerr.cause.
The rejection is the signal's reason object itself, a DOMException. A test against both Node versions confirmed err === signal.reason.
import { fetchWithTimeout } from './fetch-with-timeout.mjs';
async function getJson(url, options) {
const started = performance.now();
const took = () => `${Math.round(performance.now() - started)}ms`;
try {
const res = await fetchWithTimeout(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// The deadline still applies while the body is being read.
const body = await res.text();
return `ok after ${took()}: ${body}`;
} catch (err) {
if (err.name === 'TimeoutError') return `timed out after ${took()}`;
if (err.name === 'AbortError') return `cancelled after ${took()}`;
// Anything else is a network-level failure: DNS, refused connection, TLS…
return `failed after ${took()}: ${err.message} (${err.cause?.code})`;
}
}
const base = 'http://localhost:3000';
console.log('fast ', await getJson(`${base}/fast`, { timeout: 1000 }));
console.log('slow ', await getJson(`${base}/slow`, { timeout: 1000 }));
console.log('drip ', await getJson(`${base}/drip`, { timeout: 2000 }));
const user = new AbortController();
setTimeout(() => user.abort(), 300);
console.log('cancelled ', await getJson(`${base}/slow`, { timeout: 5000, signal: user.signal }));
console.log('no server ', await getJson('http://localhost:3999/', { timeout: 1000 }));$ node demo.mjs
fast ok after 35ms: {"ok":true}
slow timed out after 1005ms
drip timed out after 2005ms
cancelled cancelled after 306ms
no server failed after 4ms: fetch failed (ECONNREFUSED)Node 26.10 printed the same lines, give or take a few milliseconds. Note the drip line: the headers arrived fine, and the deadline still cut the request off while the body was being read.
Remember that fetch() does not reject on HTTP errors. A 404 or a 500 is a successful fetch with res.ok === false, which is why getJson checks it explicitly.
Where it works
According to MDN's compatibility data:
AbortSignal.timeout() | AbortSignal.any() | |
|---|---|---|
| Node.js | 17.3 / 16.14 | 20.3 / 18.17 |
| Chrome / Edge | 124 (103+ partially, see below) | 116 |
| Firefox | 100 | 124 |
| Safari | 16 | 17.4 |
| Deno | 1.20 | 1.39 |
One caveat: Chrome 103 to 123 supported AbortSignal.timeout(), but rejected with an AbortError instead of a TimeoutError. If you still need to tell the two apart on those versions, check deadline.aborted on the timeout signal you created rather than relying on err.name.
You no longer need the abortcontroller-polyfill package this post used to recommend. It was for Internet Explorer and legacy Edge.