How-to › Make calls that survive failure

How to enforce one total deadline across all retry attempts#

Create the deadline once before the first attempt and compose it with each per-attempt timeout, so retries and their waits come out of one budget.

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

Read first: Retry fetch calls with exponential backoff in Node.js

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Composed AbortSignal timeoutsAny fetch-based client on a current runtimeThe signal has to be threaded through every layer that makes a callThe call is made by code you cannot pass a signal to
A per-attempt timeout aloneOne attempt, no retries, and a caller who is patientNothing bounds the total, so attempts and backoff add up without a ceilingAnything retries
A deadline in the contextServices that already pass a context down every call pathA whole propagation convention, which is a large change to adopt for one clientA single client library needs bounding
p-timeoutBounding a promise that is not a fetch callIt rejects the wrapper without cancelling the work underneathThe 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.

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.

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