# How to retry fetch calls with exponential backoff in Node.js

> Retry only the statuses the server meant as temporary, back off with full jitter, and honor Retry-After, so a shared outage does not become a stampede.

Source: https://voxgig.com/howto/retry-fetch-with-backoff-in-node

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

## Short answer

Retry on 408, 425, 429 and the 5xx statuses, and return everything else to the caller untouched. Wait a random time between zero and a capped exponential delay, doubling the cap each attempt. When the response carries Retry-After, use that instead of your own schedule. Keep the schedule in its own function so a test can read it without making a request.

---
## You will need

Node 22 or later, and an API that fails temporarily. Retry-After is defined in
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#field.retry-after) and takes either a number of
seconds or an HTTP date, so a parser has to handle both.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A loop around fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch) | One or two call sites, and you want the policy visible | You own the edge cases: streams, aborts, and bodies that can only be read once | Retries are needed on every call in a large codebase |
| [The undici retry handler](https://undici.nodejs.org/#/docs/api/RetryHandler) | Node only, and you are already using a dispatcher | It is set per dispatcher, so the policy is further from the call than a wrapper around fetch | The same policy has to run in a browser as well |
| [got](https://github.com/sindresorhus/got) | You want retries, hooks and timeouts as one dependency | A larger client than fetch, and its own request and response objects to learn | You already build requests with fetch everywhere |
| [p-retry](https://github.com/sindresorhus/p-retry) | Retrying things that are not HTTP calls, with the same policy | It knows nothing about status codes, so classification stays your job | The thing being retried is only ever a request |

The loop and the library differ mostly in who owns the mistakes. A hand-written loop is twenty
lines and every one of them is yours to get right, including the one that decides a 404 is not worth
repeating. A library has those decisions made, and the cost is that changing one means reading
someone else's option names.

## Decide what is worth repeating

Retrying a status the server meant is the most common way a retry loop makes things worse.

```ts title="backoff.mjs"
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504])

export function isRetryable(status) {
  return RETRYABLE.has(status)
}
```

A 400 means the request was wrong, and it will be wrong the second time. A 404 means the thing is
not there. A 422 means the body did not validate. Repeating any of those wastes a round trip and
your quota. The set here is deliberately short, and 501 and 505 stay out of it because a server
that does not implement something will still not implement it in two seconds.

## Spread the retries out

Two clients that fail at the same moment must not retry at the same moment. Full jitter is the
schedule that spreads them best, and it is one multiplication.

```ts title="backoff.mjs"
export function delayFor(attempt, { base = 100, cap = 2000, random = Math.random } = {}) {
  const exponential = Math.min(cap, base * 2 ** (attempt - 1))
  return Math.round(random() * exponential)
}
```

Passing `random` in is what makes the schedule testable. Injecting a fixed sequence gives an exact
expected series, so a test asserts on numbers rather than on a range. The cap matters as much as the
growth: without it the eighth attempt waits over a minute, and a caller that has already given up is
still holding a socket.

```bash
node schedule.mjs
```

```text output
no jitter (random always 1)   100   200   400   800 ms
client A                       42   182    52   616 ms
client B                       68    48   220   248 ms
```

The first row is what every client waits without jitter, which is the stampede. The two rows under
it never collide, and neither of them waits longer than the cap.

Full jitter is not the only option, and the reason to prefer it is measured rather than aesthetic.
[The AWS analysis of backoff strategies](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/)
compares the alternatives under contention and finds full jitter completes the same work in fewer
calls than the half-jittered and unjittered schedules. Equal jitter keeps a floor under each wait,
which reads as safer and leaves the clients more clustered than they need to be.

## Check it worked

The interesting assertions are on the count of requests the server received and on the schedule
itself, and neither needs a real delay.

```ts title="retry.test.mjs"
test('Retry-After in seconds beats the schedule', () => {
  assert.equal(retryAfterMs('120'), 120000)
  assert.equal(retryAfterMs(null), null)
  assert.equal(retryAfterMs('not a date'), null)
})

test('two failures then a success returns the success', async () => {
  const api = await startServer({ failFirst: 2 })
  servers.push(api)

  const { res, attempt } = await retryingFetch(api.url, {}, { sleep: async () => {} })

  assert.equal(res.status, 200)
  assert.equal(attempt, 3)
  assert.equal(api.state.requests, 3)
})
```

```bash
node --test retry.test.mjs
```

```text output
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 274.507156
```

Passing `sleep` as a no-op keeps the suite fast. Real timers in a retry test buy nothing and cost
seconds on every run.

## When it goes wrong

The first failure is a retried POST. A request that timed out may have been committed by the server,
so repeating it can create a second record. Restrict automatic retries to GET, HEAD, and other safe
methods until the write carries something that lets the server recognize a repeat.

The second is a body that can only be read once. A Request built from a stream cannot be sent twice,
so a loop that reuses one object fails on the second attempt with an error about a disturbed body.
Build the request inside the loop rather than outside it.

The third is a retry budget that nobody set. Four attempts per call, times the number of clients,
times a shared outage, is a multiplier applied to a service that is already failing. Cap the
attempts and stop when the caller's deadline has passed rather than when the count runs out.

## When not to do this

Do not retry inside a library that its callers also wrap in a retry. Two layers multiply, so three
attempts each becomes nine requests for one call, and the caller cannot see why.

Do not retry a request whose result nobody is waiting for any more. A user who navigated away or a
job that has been cancelled makes the retry pure cost, which is what an `AbortSignal` is for.

Do not use exponential backoff for a rate limit the server has already told you about. When
Retry-After names a time, waiting less is rude and waiting more is slow, and the header is the one
number in the exchange that both sides agree on.

Do not treat a connection error as automatically retryable. A refused connection is often safe to
repeat, and a timeout after the request was sent is the ambiguous case that needs the same care as a
POST.

## Related how-tos

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

- [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.
The jitter rows come from a fixed random source so the schedule is reproducible.