10LOC
Node.jsintermediate

AbortController to cancel a fetch cleanly after a timeout

Published July 24, 2026

export const fetchWithTimeout = async (url: string, timeoutMs: number) => {
  const signal = AbortSignal.timeout(timeoutMs);
  try {
    const response = await fetch(url, { signal });
    return await response.text();
  } catch (err) {
    const timedOut = err instanceof Error && err.name === "TimeoutError";
    throw timedOut ? new Error(`Request to ${url} timed out after ${timeoutMs}ms`) : err;
  }
};

What

AbortSignal.timeout(ms) creates a signal that fires on its own after ms milliseconds, no setTimeout/clearTimeout bookkeeping required. Pass it to fetch's signal option and the request aborts itself if it runs too long.

Why it matters

The classic manual version — setTimeout(() => controller.abort(), ms) — works, but you own the timer's whole lifecycle: you must clear it on success or it leaks, and you must remember to build the controller in the first place. It also collapses every abort reason into the same generic AbortError, so a request you cancelled on purpose looks identical to one that timed out.

AbortSignal.timeout() fixes both. Node creates and manages the timer internally, and when it fires, the resulting DOMException has name set to "TimeoutError" specifically — not the generic "AbortError" you'd get from calling controller.abort() yourself. That distinction is what lets you retry a timeout while still surfacing a manual cancellation immediately.

How it works

  • AbortSignal.timeout(timeoutMs) returns a signal already wired to fire after timeoutMs — no controller needed since there's nothing to abort manually.
  • fetch(url, { signal }) ties the request to that signal; Node's fetch (built on undici) rejects the promise the moment the signal aborts, mid-flight or not.
  • The catch block checks err.name === "TimeoutError" to rewrap the error with the URL and duration baked in, so whoever's logging it doesn't have to guess which request timed out.
  • Any other rejection (DNS failure, connection reset, a manual abort elsewhere) is rethrown unchanged.

Gotchas

  • AbortSignal.timeout() requires Node 17.3+/16.14+ — safe on any current LTS, but worth knowing if you're targeting something older.
  • The timer keeps running even after the fetch settles on its own; it's cheap and self-cleans, but don't assume you need to cancel it — there's nothing exposed to cancel.
  • If you need both a timeout and a manual "cancel this on unmount" path, combine signals with AbortSignal.any([manualSignal, AbortSignal.timeout(ms)]) rather than juggling two separate try/catch branches.

Related

  • AbortSignal.any() — merges multiple abort sources into one signal; the natural next step once you need manual cancellation alongside a timeout.
  • events.once(emitter, "abort", { signal }) — waiting on a signal's abort event directly, instead of passing the signal into an API that already understands it.