Contents

09/26/2026

JavaScript fetch timeout: AbortSignal.timeout() and any()

fetch() has no timeout option, and without one a request to a server that stops answering can wait for minutes: in Node, undici's default limits are 300 seconds for the response headers and 300 seconds between body chunks. The fix used to be an AbortController plus a setTimeout plus a clearTimeout you had to remember. Two static methods replace all three: AbortSignal.timeout() makes a signal that aborts itself, and AbortSignal.any() combines it with a signal of your own.

export async function getText(url, { timeoutMs = 5000, signal } = {}) {
  const signals = [AbortSignal.timeout(timeoutMs)];
  if (signal) signals.push(signal); // e.g. a user's Cancel button

  const res = await fetch(url, { signal: AbortSignal.any(signals) });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
  return res.text();
}

Whichever signal fires first wins. The combined signal's reason is the reason of the one that fired, which is what lets the caller tell a timeout from a cancel.

Telling a timeout from a cancel

The two failures arrive as different error names, and they deserve different handling: a timeout is worth retrying, a user's cancel is not. It is the same split any retry with backoff makes between failures worth another attempt and final ones. Run against a local server that waits two seconds before answering, with a 500 ms timeout, Node 24.15 printed:

fast          -> ok after 50ms
slow          -> TimeoutError | The operation was aborted due to timeout
user cancel   -> AbortError   | This operation was aborted
custom reason -> Error        | route changed

The last line is the case people miss. If you call controller.abort(new Error('route changed')), fetch rejects with your error, not an AbortError. So branch on the name and let anything else through:

try {
  const body = await getText(url, { timeoutMs: 3000, signal: cancel.signal });
  render(body);
} catch (err) {
  if (err.name === 'TimeoutError') return showRetry();   // slow server
  if (err.name === 'AbortError') return;                 // user canceled, stay quiet
  throw err;                                             // real failure, or a custom abort reason
}

The error is a DOMException, so check err.name, not instanceof TimeoutError: there is no such class. The MDN page for AbortSignal.timeout() uses the same two names.

What each rejection means

err.name is 'TimeoutError'
AbortSignal.timeout() fired: the server was too slow, so offer a retry
err.name is 'AbortError'
Your own signal fired, such as a Cancel button: stay quiet
Anything else
A network failure, an HTTP error you threw, or a custom abort reason: rethrow

The timeout covers the body, too

A signal passed to fetch stays attached to the response. If headers arrive quickly and the body trickles in, the timeout can still fire while you are reading it. Against a server that sent its headers at once and the rest of the body a second later, with a 300 ms timeout:

headers arrived, status 200
body read failed: TimeoutError true

That is usually what you want, since a stalled body is as broken as a stalled connection. It does mean timeoutMs is a budget for the whole exchange, so size it for your largest expected response rather than for time to first byte. It also means the try has to wrap res.text() or res.json(), not only the fetch() call.

Other APIs report it differently

Plenty of Node APIs accept a signal, but not all of them surface the reason the same way. The promise version of setTimeout from node:timers/promises, given the same kind of timeout signal, rejected with an AbortError whose cause was the TimeoutError:

import { setTimeout as sleep } from 'node:timers/promises';

try {
  await sleep(1000, 'done', { signal: AbortSignal.timeout(50) });
} catch (err) {
  console.log(err.name, err.cause?.name); // AbortError TimeoutError
}

The reliable place to look, whatever the API, is the signal itself. signal.aborted says whether it fired and signal.reason holds what fired it:

function whyAborted(signal) {
  if (!signal.aborted) return null;
  return signal.reason?.name ?? 'unknown'; // 'TimeoutError', 'AbortError', or your own error's name
}

Keep a reference to the combined signal when you need to report which one won.

A few rules that save debugging

Make a new timeout signal per attempt. AbortSignal.timeout(5000) starts counting when it is created, not when fetch starts. Created once at module scope and reused, it expires once, and every request after that rejects with TimeoutError straight away. In a retry loop, create it inside the loop.

An already-aborted input aborts the result immediately. AbortSignal.any([AbortSignal.abort(), AbortSignal.timeout(1000)]) came back with aborted already true and a reason named AbortError, so a fetch given it rejects at once.

No cleanup is needed. There is no timer handle to clear, which removes the classic bug where a forgotten clearTimeout leaves a timer running after the request has finished, or aborts a later request that reused the same controller.

In browsers the clock pauses. MDN notes the timeout is based on active rather than elapsed time, and is effectively paused while the page sits in the back-forward cache or a worker is suspended. A tab restored from history does not count its time away against the request.

Where it runs

The Node.js globals documentation lists AbortSignal.timeout() from v17.3.0 and v16.14.0, and AbortSignal.any() from v20.3.0 and v18.17.0. Every Node release line still in support has both. In browsers, MDN marks AbortSignal.timeout() Baseline 2024 and AbortSignal.any() Baseline widely available.

If you still support something older, the long form does the same job:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new DOMException('Timed out', 'TimeoutError')), 5000);
try {
  return await fetch(url, { signal: controller.signal });
} finally {
  clearTimeout(timer);
}

Passing a DOMException named TimeoutError as the reason keeps the error handling above identical on both paths.

Questions this raises

How do I set a timeout on fetch()?

Pass signal: AbortSignal.timeout(ms) in the fetch options. The request rejects with a DOMException named TimeoutError if it has not finished in time, including the time spent reading the body.

How do I combine a timeout with my own AbortController?

Use AbortSignal.any([AbortSignal.timeout(ms), controller.signal]). The combined signal aborts when either input does, and its reason is the reason of whichever fired first.

Why does every request fail immediately with TimeoutError?

The timeout signal was created once and reused. AbortSignal.timeout() starts counting when it is created, so after it expires, every fetch given it rejects at once. Create a new one for each request or retry attempt.

Is there a TimeoutError class to use with instanceof?

No. Both TimeoutError and AbortError are DOMException instances distinguished by their name property, so check err.name.