How-to › Make calls that survive failure

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.

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

An upstream service returns 503 for ninety seconds during a deploy. Your service has no retry, so every request in that window fails and the errors reach your customers. Add a naive retry loop and the next deploy is worse: every client retries in lockstep and the service gets a stampede on the moment it comes back.

What you get

You will end up with a fetch wrapper that retries the statuses a server meant as temporary, and a delay schedule you can print and test. This is for you if a transient upstream failure reaches your users today.

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 and takes either a number of seconds or an HTTP date, so a parser has to handle both.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A loop around fetchOne or two call sites, and you want the policy visibleYou own the edge cases: streams, aborts, and bodies that can only be read onceRetries are needed on every call in a large codebase
The undici retry handlerNode only, and you are already using a dispatcherIt is set per dispatcher, so the policy is further from the call than a wrapper around fetchThe same policy has to run in a browser as well
gotYou want retries, hooks and timeouts as one dependencyA larger client than fetch, and its own request and response objects to learnYou already build requests with fetch everywhere
p-retryRetrying things that are not HTTP calls, with the same policyIt knows nothing about status codes, so classification stays your jobThe 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.

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.

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.

node schedule.mjs
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 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.

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)
})
node --test retry.test.mjs
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.

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.

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.