fetch() never times out — and Node hides a five-minute one
published
TL;DR
fetch() has no timeout option. RequestInit accepts signal and seventeen other members, and none of them is timeout — so a server that accepts your connection and then says nothing keeps the promise pending. In Node the only backstop is undici’s transport timeouts, headersTimeout and bodyTimeout, 300 seconds each. Pass signal: AbortSignal.timeout(ms) and catch the TimeoutError. Three things to know before you do: it is a total deadline that also covers reading the body, it cannot be cancelled, and it is not the same clock undici is running.
The problem
Nothing in these two lines bounds how long they take:
const res = await fetch("https://api.example.com/report");
const data = await res.json();
Not “nothing obvious” — nothing at all. A ten-line reproduction, on Node v26.2.0:
import http from "node:http";
// accepts the connection, then never writes a byte
const server = http.createServer(() => {});
await new Promise((r) => server.listen(3000, "127.0.0.1", r));
const t = Date.now();
const outcome = await Promise.race([
fetch("http://127.0.0.1:3000").then(() => "resolved"),
new Promise((r) => setTimeout(() => r("still pending"), 12_000)),
]);
console.log(outcome, Date.now() - t, "ms");
It prints still pending 12020 ms. The race is only there to end the test — without it the script sits on that socket, and the catch block you wrote for network trouble never runs, because a request that has not failed has nothing to reject with. This is the sibling of the more famous one: fetch() resolves on a 500 means your error path misses failures that did answer; this one misses failures that never answered at all.
In a browser that is a spinner that spins forever. On a server it is worse: the handler holds a socket, a connection-pool slot and the caller’s own request open while it waits on a peer that is never going to reply.
Why it happens
The Fetch standard has exactly one cancellation mechanism, and it is not a timer. RequestInit — the options object — declares attributionReporting, body, browsingTopics, cache, credentials, duplex, headers, integrity, keepalive, method, mode, priority, privateToken, redirect, referrer, referrerPolicy, signal and targetAddressSpace. Timeouts were left to the caller, to be built out of a signal. Browsers do enforce network timeouts of their own underneath, but those are unspecified and not reachable from JavaScript.
Node’s fetch is undici, and undici has two timeouts — except they are transport timeouts, not request deadlines:
headersTimeout— “The time, in milliseconds, the parser waits to receive the complete HTTP headers. Defaults to 300 seconds.”bodyTimeout— “The time, in milliseconds, after which the request times out while receiving body data. Monitors the time between body chunks. Use0to disable it entirely. Defaults to 300 seconds.”
Read that second one twice. It measures the gap between chunks, so a response that trickles forever never trips it. Three different clocks, three different failures:
| Knob | What it measures | Default | What you catch |
|---|---|---|---|
AbortSignal.timeout(ms) | total time, request start through body read | none — opt-in | DOMException named TimeoutError |
undici headersTimeout | time until the complete response headers arrive | 300 s | TypeError: fetch failed, cause.code UND_ERR_HEADERS_TIMEOUT |
undici bodyTimeout | gap between two body chunks | 300 s | TypeError: terminated, cause.code UND_ERR_BODY_TIMEOUT |
The distance between rows one and three is easy to measure. Against a local server that writes one chunk every 300 ms for three seconds:
signal: AbortSignal.timeout(1500)threwTimeoutErrorat 1,506 ms, killing a download that was streaming perfectly well.dispatcher: new Agent({ bodyTimeout: 1000 })delivered all 60 bytes in 3,381 ms. The 300 ms gaps never came close to a between-chunks timer, even though the transfer took three times the “timeout”.
One piece of history is worth carrying. Those 300-second defaults have moved before: Node v18.14.1 shipped an undici that cut both to 30 seconds, and nodejs/node#46706 — “18.14.1 release altered fetch timeout defaults from 300s to 30s”, opened 18 February 2023 — collected the applications whose long requests suddenly died with HeadersTimeoutError on a patch release. It was reverted in undici and the revert rolled back into Node. The lesson stands: five minutes is a default you inherit from a dependency, not a contract. Set your own.
What to do
1. Give every call a deadline.
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
When it expires, the promise rejects with a DOMException whose name is TimeoutError and whose message is The operation was aborted due to timeout (Node v26.2.0). A cancel from your own AbortController arrives as AbortError instead, so a single catch can tell “the network was too slow” apart from “the user left”. AbortSignal.timeout() has been Baseline newly available since April 2024.
2. Keep the user’s cancel and the deadline. AbortSignal.any() combines them, and whichever fires first supplies the reason:
export async function fetchJson(url, { timeoutMs = 10_000, signal, ...init } = {}) {
const timeout = AbortSignal.timeout(timeoutMs);
const res = await fetch(url, {
...init,
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText} from ${url}`);
}
return res.json();
}
Because the signal stays attached to the response, the budget covers res.json() too — a server that sends headers fast and then dribbles the body still hits the deadline. Measured against a stalling endpoint: TimeoutError at 1,018 ms for a 1,000 ms budget, and AbortError at 305 ms when the caller aborted first.
try {
const data = await fetchJson("/api/report", { timeoutMs: 2000 });
} catch (err) {
if (err.name === "TimeoutError") {
// budget blown — retry, degrade, or show stale data
} else if (err.name === "AbortError") {
// the caller cancelled; not an error worth logging
} else throw err;
}
3. In Node, raise the transport ceiling for calls that legitimately run long — streaming model responses, large uploads, slow report endpoints. That is what undici’s non-standard dispatcher option is for, and the global fetch honours it:
import { Agent } from "undici";
const patient = new Agent({ headersTimeout: 600_000, bodyTimeout: 0 });
const res = await fetch(url, {
dispatcher: patient,
signal: AbortSignal.timeout(900_000), // still keep a real ceiling
});
Verified with npm undici 8.10.2 on Node v26.2.0, whose built-in undici is 8.3.0. Keep that signal: bodyTimeout: 0 disables the between-chunks timer completely — in a test with a body that stopped mid-stream, the read was still waiting after 8 seconds with no error — so removing it without a total deadline trades a five-minute hang for a permanent one.
Caveats
- The deadline includes the download.
AbortSignal.timeout(30_000)on a 2 GB file aborts a perfectly healthy transfer at 30 seconds. For big or streaming bodies, either budget generously or police the stream yourself instead of putting the whole exchange on one clock. - The timeout cannot be cancelled, and
AbortSignal.any()does not cancel it. Combining a controller with a timeout and aborting the controller at 0 ms, the underlying timeout signal still fired on schedule at 810 ms. In Node the stray timer does not hold the process open — a script whose only pending work wasAbortSignal.timeout(30_000)exited after 3 ms — but a long-lived browser page accumulates them for as long as you keep making them. - In browsers the clock counts active, not elapsed, time. MDN: the timeout “will effectively be paused if the code is running in a suspended worker, or while the document is in a back-forward cache”. A backgrounded tab can outlive its own deadline.
- undici’s parser timeouts are not millisecond-precise. A
headersTimeoutof 1,000 ms fired at 1,549, 1,535 and 1,533 ms across three runs. The docs say so directly: larger delays use undici’s “lower-overhead fast timers with a target resolution around 500ms”. Do not build a tight SLA on them. - A timeout ends your wait, not the server’s work. Aborting tears down your side of the connection; whether the origin keeps processing is entirely up to the origin. For anything non-idempotent, a retry after a timeout can run the operation twice.
dispatcheris undici’s, so it is Node-only. Browser code getsAbortSignal.timeout()and nothing below it.