How-to › Make calls that survive failure

How to time out fetch calls with AbortSignal in Node.js#

Put a hard deadline on every fetch call with AbortSignal.timeout, merge it with a caller's own signal, and tell a deadline apart from a cancellation.

Audience
API consumer
Level
intermediate
Topic
Set retries and timeouts
Languages
TypeScript and JavaScript
Verified

A vendor endpoint stops answering. The socket stays open, the promise never settles, and the request handler that called it keeps its database connection while it waits. Node’s built-in fetch applies no deadline of its own, so one slow dependency fills the connection pool and every other route slows down behind it.

What you get

You will end up with one call wrapper that gives every request a deadline, and a catch block that can say whether the deadline fired or the caller gave up. This is for you if your service calls an API you do not run and has no upper bound on how long a call takes.

Short answer

Pass AbortSignal.timeout(ms) as the signal on every fetch call, and merge it with any signal the caller handed you using AbortSignal.any. The call rejects with a TimeoutError when the budget runs out and an AbortError when the caller cancels, so one catch block can separate a slow dependency from a client that walked away.

You will need

Node 22 or later, and an endpoint whose latency you do not control. Everything here uses the global fetch and the AbortSignal statics, both built in.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
AbortController and a timerYou target a runtime older than the statics, or you want to cancel from elsewhereFour lines per call site, a timer you have to clear, and an AbortError that hides the reasonThe runtime has AbortSignal.timeout, which is the same thing written once
AbortSignal.timeoutA single budget covering connect, headers, and body, in any runtime with fetchOne number for phases that fail differently, so a slow download dies on a header budgetYou need separate limits for the handshake and for a long response body
got timeout objectYou already use got and want lookup, connect, response, and request limits named apartA client library and its dependency tree, in place of a function the runtime shipsPlain fetch covers the work and a new dependency has to earn its place
undici dispatcher timeoutsheadersTimeout and bodyTimeout across a whole pool, applied whether or not a caller remembers a signalConfiguration that lives away from the call, so a reader of the call cannot see the limitThe deadline belongs to one request rather than to every request on the agent

The split that matters is between one number and several. AbortSignal.timeout gives the whole call a single budget, which is what a caller upstream actually has: a request handler with 2 seconds left does not care which phase spends them. undici’s dispatcher options give separate limits for headers and for body, which is what a large download needs. They also apply to every request through that agent, whether or not the call site remembers to pass anything. That is a real difference in where the policy lives: one is visible in the line you are reading, the other is set once at startup and invisible at the call.

Give every call one budget

Build the deadline inside the wrapper rather than at each call site, so no request can be written without one.

export async function fetchWithDeadline(url, { timeoutMs, signal, ...init } = {}) {
  const deadline = AbortSignal.timeout(timeoutMs)
  const merged = signal ? AbortSignal.any([deadline, signal]) : deadline
  const response = await fetch(url, { ...init, signal: merged })
  return response
}

AbortSignal.any settles on whichever of its inputs fires first and keeps the reason that input carried. That is the reason to merge rather than to pick one. A request a client cancelled and a request that ran out of budget both stop the same fetch. Only the reason they carry tells them apart afterwards, and a caller that hangs up is not an incident.

Read the reason, not the clock

One function turns the two error names into words your logs and metrics can group by.

export function classify(error) {
  if (error.name === 'TimeoutError') return 'deadline'
  if (error.name === 'AbortError') return 'cancelled'
  return 'transport'
}

Branch on error.name and nothing else.2 The message text differs between runtimes and between Node releases, and code that matches on it breaks at an upgrade with no compile error to warn you.

The third bucket earns its place. A DNS failure, a refused connection, and a TLS handshake error all arrive here as transport errors rather than deadlines. Folding them into the timeout count makes a broken DNS entry look like a slow vendor, which sends the next person to the wrong dashboard.

Watch the body get killed too

A fired signal aborts the response stream as well as the request. That is the behaviour most likely to surprise, so the sample runs it against a server that answers at once and then trickles.

node demo.mjs
headers stall          -> deadline (TimeoutError)
body trickles          -> deadline (TimeoutError)
caller cancels         -> cancelled (AbortError)
inside the budget      -> ok 200 12 bytes

Line two is the one to read twice. The status line arrived inside the budget and fetch resolved, so the code held a Response object and believed the call had succeeded. Reading the body then threw, because the same signal covers both. A deadline sized for a fast JSON reply will cut off a report that streams for half a minute. The failure surfaces at the await on the body, several lines away from the call that set the budget.

Check it worked

Run the tests. The second one asserts the shape that catches people out: a resolved response whose body read rejects.

node --test deadline.test.mjs
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 889.484949

Four passes and no skips.3 The second test is the contract: the response resolves with a status of 200, and the body read rejects with a deadline. A wrapper that only tests the stalled-headers case will pass while leaving the download failure uncovered.

When it goes wrong

The error arrives as AbortError when a deadline fired. Passing a controller’s signal instead of AbortSignal.timeout loses the distinction, because a cancellation you scheduled with a timer is indistinguishable from one a caller asked for. Use the static, or pass a reason to controller.abort() and read signal.reason.

Every request shares one signal. Creating the signal once at module scope and reusing it means the first timeout poisons every later call, since an aborted signal stays aborted. Build the signal inside the call.

The timeout never fires on a connect to a black-holed host. AbortSignal.timeout starts counting when it is created, so a signal built before a long queue wait has already spent part of its budget by the time fetch sees it. Create it immediately before the call.

When not to do this

Do not give a streaming endpoint the same budget as a JSON one. Server-sent events, log tails, and large exports are meant to stay open, and a call deadline ends them mid-flight with a TimeoutError that reads like a fault. Size those by inactivity instead, with a timer the reader resets on each chunk.

Do not set the deadline from the slowest response you have ever seen. A budget that generous never protects anything, because the pool is exhausted long before the number is reached. Take it from the caller’s remaining time.

Do not treat a deadline as a retry trigger without checking the verb. A POST that timed out may have been applied, and repeating it duplicates the write. Retry the safe verbs, and give the rest an idempotency key first.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The statics come from the DOM Standard, whose section on aborting ongoing activities sits between the section on events and the section on nodes. Node has no document and implements the section anyway. Its documentation describes AbortController as based on the Web API, and dates timeout to 17.3.0 and any to 20.3.0, with a second, smaller number beside each. ↩︎ Back to text

  2. Both names come from the DOMException names table in Web IDL, which describes each in four words and gives each a number under the heading legacy code. AbortError is 20 and TimeoutError is 23. The table holds 33 names. Twenty-two of them carry a number from 1 to 25, with 2, 6 and 16 absent, and the other eleven carry a dash where a number would go. Node’s documentation covers its copy in one sentence, as the WHATWG DOMException class, and leaves the numbers to the table. ↩︎ Back to text

  3. The summary is TAP, the Test Anything Protocol, and its first line 1..4 is the plan. The protocol’s history page traces it to Perl 1.0, where a script called t/TEST understood only the 1..M header, ok and not ok. The commit is by Larry Wall and dated January 1988. Node’s runner wrote it here because standard output was a pipe rather than a terminal. Its reporter documentation records that 23.0.0 changed the default for that case from tap to spec, so the plan line is a fossil of the release named under Last verified. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.