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

Source: https://voxgig.com/howto/enforce-a-total-deadline-across-retry-attempts

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## 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](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static), 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](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static) | 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](https://nodejs.org/api/globals.html#class-abortsignal) | 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](https://pkg.go.dev/context#WithDeadline) | 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](https://github.com/sindresorhus/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.

```ts title="deadline.mjs"
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](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static) 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.

```bash
node demo.mjs
```

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

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

```text output
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](https://grpc.io/docs/guides/deadlines/) 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

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

- [Add a circuit breaker to an outbound HTTP client](/howto/add-a-circuit-breaker-to-an-http-client)

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