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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A loop around 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 | 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 | 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 | 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.
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.
Related how-tos
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.