Writing · Article

AbortSignal.timeout() and AbortSignal.any(): Request Timeouts Without the setTimeout Dance

Stop hand-rolling AbortController timeouts. How AbortSignal.timeout() and AbortSignal.any() work, how to tell a timeout from a user cancel, and what to watch for in Node.

Almost every codebase has this function in it somewhere. Someone needed a fetch with a timeout, discovered that fetch does not have one, found AbortController, and wrote the obvious thing:

async function fetchWithTimeout(url, ms) {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), ms)
  try {
    return await fetch(url, { signal: controller.signal })
  } finally {
    clearTimeout(timer)
  }
}

It works. It is also four moving parts to express one idea, it leaks a timer if you forget the finally, and the error it throws is indistinguishable from a user pressing cancel. All three of those have had a platform fix for a while now, and a surprising number of codebases have not picked it up.

AbortSignal.timeout() is the whole thing

AbortSignal.timeout(ms) is a static method that hands you a signal which aborts itself after ms milliseconds. No controller, no timer variable, no cleanup.

const res = await fetch(url, { signal: AbortSignal.timeout(5000) })

That is the entire replacement for the function above. It has been available in Node since 17.3, and it is supported across current browsers.

Two details make it better than the hand-rolled version rather than merely shorter. First, there is no timer for you to leak: the signal owns its own timer and the platform cleans it up. In Node specifically, that timer does not hold the event loop open, so a script whose only pending work is an unfired timeout signal still exits cleanly. Second, and more usefully, it aborts with a different error.

TimeoutError vs AbortError: the part people miss

When you call controller.abort() yourself, the rejection is a DOMException whose name is "AbortError". When a timeout signal fires, the name is "TimeoutError". That distinction is free, and it is the difference between a useful error path and a useless one.

try {
  const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
  return await res.json()
} catch (err) {
  if (err.name === 'TimeoutError') {
    // the server was too slow - retry, or degrade
    return null
  }
  if (err.name === 'AbortError') {
    // the user navigated away - say nothing, do nothing
    return null
  }
  throw err
}

In the hand-rolled version both cases arrive as AbortError, which is why so many apps show a Something went wrong toast to users who simply clicked the back button. If you take one thing from this post, take this: stop swallowing every abort identically.

AbortSignal.any() composes signals

Real requests usually have more than one reason to stop. A timeout, and a cancel button, and a React effect cleanup when the component unmounts. Before AbortSignal.any() you either nested controllers or wired abort listeners by hand.

AbortSignal.any(iterable) takes any iterable of signals and returns one signal that aborts as soon as the first of them does. The reason on the combined signal is the reason of whichever signal won, so the err.name check above still works unchanged.

function load(url, userSignal) {
  const signal = AbortSignal.any([
    userSignal,
    AbortSignal.timeout(10_000),
  ])
  return fetch(url, { signal })
}

It became newly available across browsers in Baseline 2024, and landed in Node 20. In React that gives you a clean effect:

useEffect(() => {
  const controller = new AbortController()
  const signal = AbortSignal.any([
    controller.signal,
    AbortSignal.timeout(8000),
  ])

  fetch('/api/items', { signal })
    .then((r) => r.json())
    .then(setItems)
    .catch((err) => {
      if (err.name === 'TimeoutError') setError('The server took too long.')
      // AbortError here means unmount - deliberately ignored
    })

  return () => controller.abort()
}, [])

The sharp edges

Three things are worth knowing before you put AbortSignal.any() on a hot path.

  • There is no way to unsubscribe. A combined signal cannot be detached from its inputs. Aborting the combined signal does not abort the inputs or cancel their timeouts either - the composition is one-directional.
  • Listeners still need removing. The spec links combined signals to their sources through weak references, but a non-aborted combined signal is kept alive while it has source signals and registered abort listeners. If you attach your own listener to a combined signal, remove it when the operation finishes, exactly as you would for any other signal.
  • Creating one per request in a tight loop is real work. For a handful of concurrent requests this is irrelevant. For thousands, hoist the long-lived signals and only build the per-request timeout.

There is also one genuine reason to keep the old pattern: if you need to extend or reset the deadline while the request is in flight - a heartbeat-style idle timeout rather than a total-duration timeout - AbortSignal.timeout() cannot help you. Its clock starts when the signal is created and cannot be restarted. That is a case for a controller and a timer you own.

It is not only for fetch

Both methods return a plain AbortSignal, so anything that accepts one accepts these. In the browser that includes addEventListener - passing a signal in the options object removes the listener when the signal aborts, which is the tidiest way to clean up a group of listeners at once.

const signal = AbortSignal.timeout(30_000)
window.addEventListener('scroll', onScroll, { signal })
window.addEventListener('resize', onResize, { signal })
// both listeners detach automatically after 30 seconds

In Node, most of the async APIs that can be cancelled take a signal option too - fs/promises reads, readline, child_process, stream helpers, timers promises. The same signal can gate all of them.

What to change today

  • Grep for new AbortController() followed by a setTimeout. Nearly all of those are one-line replacements.
  • Grep for err.name === 'AbortError' and check whether the branch is really meant to catch timeouts too. Split it.
  • Where a request has more than one cancellation source, compose with AbortSignal.any() instead of threading controllers through call signatures.
  • Keep a hand-rolled controller only where you need to move the deadline after the fact.

If you want the broader picture of how signals, reasons and throwIfAborted() fit together, the practical guide to AbortController and AbortSignal covers the fundamentals this post builds on.