Fetch timeouts in Node with AbortController

· js node

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.