Fetch timeouts in Node with AbortController
Node has had a global fetch since v18, and it's stable now — no flag, no
node-fetch dependency. The catch people hit in production: a hung server, and
your request just sits there. fetch has no short default timeout, so a slow or
dead upstream can pin a request open far longer than you'd ever want. You add the
timeout yourself, and the modern way is small.
The one-liner: AbortSignal.timeout
AbortSignal.timeout(ms) returns a signal that aborts on its own after the given
milliseconds. Hand it to fetch and you're done:
const res = await fetch("https://api.example/data", {
signal: AbortSignal.timeout(5000),
});
const data = await res.json();
If the response doesn't arrive within five seconds, fetch rejects. No manual
timer, no cleanup. This is the form to reach for first — it covers the common
case in one line, and it's available in Node 18+ and modern browsers alike.
Catch the timeout specifically
When the timeout fires, the promise rejects with a DOMException whose name is
"TimeoutError". A user-triggered cancel (more on that below) rejects with
"AbortError". Tell them apart so you can react correctly:
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (err.name === "TimeoutError") {
// upstream was too slow — retry or surface a clear message
} else if (err.name === "AbortError") {
// someone cancelled it on purpose
} else {
throw err; // network error, bad JSON, non-2xx, etc.
}
}
Note that fetch only rejects on network-level failures and aborts — an HTTP
500 is a successful fetch with res.ok === false. Check the status yourself.
The manual form, and why you'd use it
AbortSignal.timeout is great until you need to cancel for a second reason —
the user navigated away, a parent operation was aborted, you got the result from
a faster source. For that you control the timer yourself with an
AbortController:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
return await res.json();
} finally {
clearTimeout(timer); // don't leak the timer if the request finished first
}
The clearTimeout in finally is the part people forget. Without it, a request
that completes in 200 ms still leaves a timer armed for the full five seconds,
keeping the event loop busy and, in a hot loop, piling up.
Combine a timeout with a real cancel: AbortSignal.any
The clean way to say "abort if either the timeout fires or the caller
cancels" is AbortSignal.any (Node 20.3+). It merges several signals into one
that trips on whichever fires first:
async function getJSON(url, { signal, timeoutMs = 5000 } = {}) {
const signals = [AbortSignal.timeout(timeoutMs)];
if (signal) signals.push(signal); // caller's own cancel signal
const res = await fetch(url, { signal: AbortSignal.any(signals) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Now a caller can pass its own AbortController to cancel early, and the timeout
still applies independently. No nested timers, no manual bookkeeping.
The gotcha: the body is a second phase
A timeout on fetch() covers getting the response and streaming its body,
because the signal stays attached to the whole operation. But the work you do
after await res.json() is yours to bound. If you read the body manually in
chunks, or hand the stream somewhere else, the same signal must travel with it —
otherwise a stall mid-body slips past your timeout. For the common
await res.json() path you're covered; just don't assume the signal protects
code that runs after the response object is already in hand.
What to actually do
Reach for AbortSignal.timeout(ms) by default — it's one line and handles the
case that bites you. Drop to a manual AbortController only when you need a
second cancellation reason, and use AbortSignal.any to fold the two together.
Always branch on err.name so a timeout and a deliberate cancel don't get logged
as the same thing. That small wrapper is the difference between a service that
degrades gracefully under a slow dependency and one that quietly piles up hung
requests until it falls over.
Common problems
TypeError: AbortSignal.timeout is not a function
Your Node is too old — AbortSignal.timeout landed in 17.3 and is in every
Node 18+. Upgrade Node, or fall back to the manual AbortController +
setTimeout form, which works on older versions.
AbortSignal.any is not a function
AbortSignal.any is newer (Node 20.3+). On an older runtime, combine signals
manually: create one AbortController, and call .abort() from both a
setTimeout and the caller's signal.addEventListener("abort", ...).
My fetch still hangs even though I passed a signal
The signal covers getting the response and streaming its body, but not the work
you do after await res.json(). If the upstream sends headers fast then stalls
mid-body the signal still fires — but a slow JSON parse or follow-up call after
the response is already in hand is on you to bound separately. Also double-check
you actually passed the signal into the fetch options object and didn't shadow
it.