# 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.

Source: https://voxgig.com/howto/time-out-fetch-with-abortsignal-in-node

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://nodejs.org/api/globals.html#fetch) and the
[AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) statics, both built in.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [AbortController and a timer](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) | You target a runtime older than the statics, or you want to cancel from elsewhere | Four lines per call site, a timer you have to clear, and an `AbortError` that hides the reason | The runtime has `AbortSignal.timeout`, which is the same thing written once |
| [AbortSignal.timeout](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout) | A single budget covering connect, headers, and body, in any runtime with fetch | One number for phases that fail differently, so a slow download dies on a header budget | You need separate limits for the handshake and for a long response body |
| [got timeout object](https://github.com/sindresorhus/got/blob/main/documentation/6-timeout.md) | You already use got and want `lookup`, `connect`, `response`, and `request` limits named apart | A client library and its dependency tree, in place of a function the runtime ships | Plain fetch covers the work and a new dependency has to earn its place |
| [undici dispatcher timeouts](https://undici.nodejs.org/#/docs/api/Client) | `headersTimeout` and `bodyTimeout` across a whole pool, applied whether or not a caller remembers a signal | Configuration that lives away from the call, so a reader of the call cannot see the limit | The 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](https://undici.nodejs.org/#/docs/api/Dispatcher) 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.

```js title="deadline.mjs"
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.

```js title="deadline.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test deadline.test.mjs
```

```text output
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.

## Related how-tos

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

- [Enforce one total deadline across all retry attempts](/howto/enforce-a-total-deadline-across-retry-attempts)

## Last verified

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

[^1]: The statics come from the [DOM Standard](https://dom.spec.whatwg.org/#interface-AbortSignal),
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`](https://nodejs.org/api/globals.html#static-method-abortsignaltimeoutdelay) to 17.3.0
and [`any`](https://nodejs.org/api/globals.html#static-method-abortsignalanysignals) to 20.3.0,
with a second, smaller number beside each.

[^2]: Both names come from the `DOMException` names table in
[Web IDL](https://webidl.spec.whatwg.org/#idl-DOMException-error-names), 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](https://nodejs.org/api/globals.html#class-domexception), and
leaves the numbers to the table.

[^3]: The summary is TAP, the Test Anything Protocol, and its first line `1..4` is the plan. The
protocol's [history page](https://testanything.org/history.html) 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](https://nodejs.org/api/test.html#test-reporters) 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.