AbortSignal.timeout() rejects with TimeoutError, not AbortError
published
TL;DR
AbortSignal.timeout(ms) does not produce an AbortError. It aborts with a TimeoutError DOMException, so the near-universal if (err.name === 'AbortError') guard silently takes the wrong branch on every timeout. Check err.name === 'TimeoutError' too, or stop matching on names and compare against the signal you own.
The problem
Here is the shape almost every fetch wrapper has:
async function get(url, signal) {
try {
return await fetch(url, { signal })
} catch (err) {
if (err.name === 'AbortError') return null // caller went away, not our problem
throw err // real failure, report it
}
}
Swap in a timeout and the guard stops working:
await get('https://example.com', AbortSignal.timeout(2000))
On Node 26.2.0, the rejection looks like this:
name=TimeoutError | message="The operation was aborted due to timeout" | code=23
versus a controller.abort() with no argument:
name=AbortError | message="This operation was aborted" | code=20
So err.name === 'AbortError' evaluates to false for the timeout. Depending on which way your guard points, you either report every timeout as an unexplained crash, or — worse — you swallow real user cancellations while retrying timeouts forever.
Why it happens
Both cases are DOMException instances and both come from the same abort machinery, but the spec deliberately gives them different names so that callers can tell “nobody is listening any more” apart from “the server was too slow”. MDN states it plainly:
The signal aborts with a
TimeoutErrorDOMExceptionon timeout.
The name is the whole distinction. DOMException has no separate class per name — TimeoutError and AbortError are the same constructor with a different name string (and a different legacy code, 23 versus 20).
There is a second way the name check fails, and it is the one that catches people who already know about the first. AbortController.prototype.abort() takes an optional reason, and whatever you pass becomes signal.reason verbatim:
const ac = new AbortController()
ac.abort(new Error('boom'))
ac.signal.reason.name // 'Error'
ac.signal.reason instanceof DOMException // false
Pass a reason and you do not get a DOMException at all. Any library in your stack that calls abort(someReason) — a few HTTP clients and test helpers do — defeats a name check that had nothing to do with timeouts.
AbortSignal.any() inherits all of this. It forwards the reason from whichever signal fired first, so the name you end up with depends on a race:
| What fired first | reason.name | reason.message |
|---|---|---|
AbortSignal.timeout(ms) | TimeoutError | The operation was aborted due to timeout |
controller.abort() | AbortError | This operation was aborted |
controller.abort(new Error('boom')) | Error | boom |
What to do
If you only need the two built-in cases, widen the check and say which is which:
try {
return await fetch(url, { signal: AbortSignal.timeout(2000) })
} catch (err) {
if (err.name === 'TimeoutError') {
// the server was slow — this is usually worth retrying and worth surfacing
throw new Error(`timed out after 2000ms: ${url}`, { cause: err })
}
if (err.name === 'AbortError') {
return null // the caller cancelled — do not retry, do not alarm
}
throw err
}
If you combine a timeout with a caller-supplied signal, stop matching on names entirely and ask the signals themselves. This is robust against custom reasons too, because you are not inspecting the error at all:
async function get(url, callerSignal) {
const timeout = AbortSignal.timeout(2000)
const signal = callerSignal
? AbortSignal.any([timeout, callerSignal])
: timeout
try {
return await fetch(url, { signal })
} catch (err) {
if (timeout.aborted) throw new Error('upstream timed out', { cause: err })
if (callerSignal?.aborted) return null
throw err
}
}
timeout.aborted and callerSignal.aborted are booleans you own. A library that aborts with a bare Error, a future spec that renames something, a DOMException polyfill — none of them change the answer.
Caveats
- This is not Node-specific.
AbortSignal.timeout()reached Baseline in April 2024 and browsers follow the same spec text. The Node results above are just what I ran; the behaviour is the standard one. - Node rejects with the
DOMExceptiondirectly for a timed-outfetch— it is not wrapped in aTypeError: fetch failed, and there is nocauseto unwrap. Higher-level HTTP clients may still wrap it, so check what your client actually throws before copying the guard. - Availability.
AbortSignal.timeout()landed in Node v17.3.0 and v16.14.0;AbortSignal.any()is newer, v20.3.0 and v18.17.0. On anything older you are hand-rolling both withsetTimeoutplus anAbortController, and then the error is whatever you chose to abort with. - The timer does not hold the event loop open. A pending
AbortSignal.timeout(3000)— even wrapped in anAbortSignal.any()— does not keep Node running: a script whose only outstanding work is that signal exits in single-digit milliseconds. Convenient, but it also means you cannot lean on the signal to keep a short-lived process alive while you wait. - The names are not interchangeable with HTTP semantics. A
TimeoutErrorhere means your client gave up, not that the server returned 408. Do not map it straight onto a status code.