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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| AbortController and a timer | 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 | 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 | 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 | 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 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.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
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
AbortControlleras based on the Web API, and datestimeoutto 17.3.0 andanyto 20.3.0, with a second, smaller number beside each. ↩︎ Back to text -
Both names come from the
DOMExceptionnames table in Web IDL, which describes each in four words and gives each a number under the heading legacy code.AbortErroris 20 andTimeoutErroris 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 WHATWGDOMExceptionclass, and leaves the numbers to the table. ↩︎ Back to text -
The summary is TAP, the Test Anything Protocol, and its first line
1..4is the plan. The protocol’s history page traces it to Perl 1.0, where a script calledt/TESTunderstood only the1..Mheader,okandnot 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 fromtaptospec, so the plan line is a fossil of the release named under Last verified. ↩︎ Back to text