Your handler has a three second timeout on each upstream call and retries four times. A slow dependency turns that into twelve seconds of waiting plus the backoff between attempts, while the caller gave up at five. The per-attempt timeout is doing its job, and nothing is enforcing the only number that matters to whoever is waiting.
What you get
You will end up with a call that stops at a budget you set, however many attempts it managed inside it. This is for you if a retry loop can outlive the request that started it.
Short answer
Create one AbortSignal.timeout for the whole call before the first attempt, and combine it with a fresh per-attempt timeout using AbortSignal.any. The total signal keeps counting while the client sleeps between attempts, so four attempts of three seconds cannot add up to twelve. Check the total before starting each attempt and stop when it has fired.
You will need
Node 22 or later, for AbortSignal.any, which composes signals so the first to fire aborts the request. Older runtimes need a manual controller that listens to both, which is a dozen lines and the same behavior.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
Composed AbortSignal timeouts | Any fetch-based client on a current runtime | The signal has to be threaded through every layer that makes a call | The call is made by code you cannot pass a signal to |
| A per-attempt timeout alone | One attempt, no retries, and a caller who is patient | Nothing bounds the total, so attempts and backoff add up without a ceiling | Anything retries |
| A deadline in the context | Services that already pass a context down every call path | A whole propagation convention, which is a large change to adopt for one client | A single client library needs bounding |
| p-timeout | Bounding a promise that is not a fetch call | It rejects the wrapper without cancelling the work underneath | The underlying call accepts an AbortSignal |
The difference between the first two is where the budget lives. A per-attempt timeout bounds one try. The caller is waiting on the whole call, backoff included. Those are the same number only when there is exactly one attempt, which is the case a retry loop exists to avoid.
Create the budget before the first attempt
The total signal is made once, outside the loop, which is the whole mechanism.
export async function callWithDeadline(url, { totalMs = 1000, attemptMs = 300, attempts = 5 } = {}) {
const total = AbortSignal.timeout(totalMs)
const started = Date.now()
const log = []
for (let attempt = 1; attempt <= attempts; attempt++) {
if (total.aborted) break
const signal = AbortSignal.any([total, AbortSignal.timeout(attemptMs)])
try {
const res = await fetch(url, { signal })
log.push({ attempt, at: Date.now() - started, outcome: `status ${res.status}` })
if (res.ok) return { ok: true, attempt, log, elapsed: Date.now() - started }
} catch (err) {
log.push({ attempt, at: Date.now() - started, outcome: err.name })
if (total.aborted) break
}
}
return { ok: false, log, elapsed: Date.now() - started }
}
Three details carry the behavior. The total is created before the loop, so time spent sleeping
between attempts counts against it. A fresh per-attempt signal is created inside the loop, because a
timeout signal fires once and a reused one aborts every later attempt immediately. And the loop checks total.aborted at the top as well as in the catch. A budget that expires during
a backoff sleep then stops the next attempt before it opens a socket.
Tell the two timeouts apart
Both timeouts cancel the same request, and the caller usually wants to know which one fired. Read
total.aborted after catching: when it is true the budget is gone and retrying is pointless, and
when it is false the attempt was slow and another one may still fit. Composing the signals loses
that distinction unless you check, because
AbortSignal.any reports
only that something aborted.
Set the per-attempt timeout well under the total. An attempt timeout equal to the budget gives one attempt and a retry loop that never runs. A third of the budget leaves room for two full attempts and the waits between them.
Where the budget itself comes from is the question this pushes up a level. A user-facing request usually has a number somebody has already chosen: the page load target, the gateway timeout, the patience of the client on the other end. Take the budget from that and subtract what the rest of the handler needs, rather than picking a round number per dependency. A service that sets each dependency’s timeout independently cannot answer how long its own handler can take. That number is what its callers are holding it to, and it is the one they will quote back when the page is slow.
Check it worked
The server here holds every request open for five seconds, so the client’s budget is the only thing that can end the call. Timings differ between runs, so the assertions are on the invariants.
node demo.mjs
succeeded: false
stopped by the attempt cap: false
every attempt timed out: true
more than one attempt was made: true
finished within 1000ms plus a 400ms margin: true
requests reaching the server match attempts made: true
The second line is the one that proves the deadline did the work: the loop was allowed fifty attempts and stopped long before reaching them.
node --test deadline.test.mjs
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5880.394793
When it goes wrong
The budget is enforced and the work continues anyway. Aborting a fetch stops the client waiting, and the server may already be committing whatever the request asked for. A deadline bounds what you wait for, and it does not undo what the other side did.
The second failure is a deadline that nobody passed down. A handler with a five second budget that calls three services, each with its own five second budget, has a fifteen second worst case. Pass the remaining time down to each layer as an explicit argument or in a header. The gRPC deadline documentation describes the propagating version of the same idea. Each layer then bounds itself by what is left rather than by its own default.
When not to do this
Do not set a deadline shorter than one attempt at the work. A budget that cannot fit a single successful call fails every request and looks like an upstream outage while being a configuration mistake.
Do not reuse one timeout signal across attempts. It fires once, and every attempt after that aborts before the request leaves, which reads in the logs as a very fast upstream failure.
Do not apply one budget to a streaming response. A download that takes two minutes is not a stalled call, and a total deadline cancels it at the point it was working. Bound the time to first byte and the idle gap between chunks instead.
Do not treat the deadline as a substitute for a circuit breaker. It bounds each call, and it lets every call keep paying that full cost against a dependency that is down.
Related how-tos
Last verified
Verified 2026-09-06 against Node 22.22.2. Both output blocks are what the preceding command printed, against a local server that holds requests open rather than a live dependency.