~/blog

AbortSignal.timeout() rejects with TimeoutError, not AbortError

published

#javascript#fetch#node#errors

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 TimeoutError DOMException on 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 firstreason.namereason.message
AbortSignal.timeout(ms)TimeoutErrorThe operation was aborted due to timeout
controller.abort()AbortErrorThis operation was aborted
controller.abort(new Error('boom'))Errorboom

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

References