~/blog

fetch() never times out — and Node hides a five-minute one

published

#javascript#fetch#node

A thin neon green progress bar across a black background, cut a third of the way along by a hot pink square block, with the final stretch of the bar left grey
Generated illustration

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:

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:

KnobWhat it measuresDefaultWhat you catch
AbortSignal.timeout(ms)total time, request start through body readnone — opt-inDOMException named TimeoutError
undici headersTimeouttime until the complete response headers arrive300 sTypeError: fetch failed, cause.code UND_ERR_HEADERS_TIMEOUT
undici bodyTimeoutgap between two body chunks300 sTypeError: 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:

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

References