Retry an async call with backoff
Retries a failing async function with exponential delay and a capped ceiling, so a flaky call gets a few spaced-out chances, not a flood.
Last updated
async function retryWithBackoff(fn, { retries = 3, baseMs = 200, maxMs = 5000 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt >= retries) throw err;
const delay = Math.min(baseMs * 2 ** attempt, maxMs);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
// await retryWithBackoff(() => fetch(url).then((r) => r.json()));How it works
The loop has no upper bound of its own — it relies on the attempt >= retries check inside the catch block to eventually rethrow, so the LAST failure's error is always the one that surfaces to the caller, not a generic "gave up" message. That preserves whatever diagnostic information fn's own error carried.
baseMs * 2 ** attempt is the exponential part: delays go 200ms, 400ms, 800ms, 1600ms... roughly doubling each retry so a struggling dependency gets increasing breathing room instead of being hammered at a fixed interval. Math.min against maxMs stops that growth from reaching minutes-long waits after enough attempts.
Edge cases to know
- →Only retry idempotent operations — a GET, or a write with its own dedupe key. Retrying a plain POST (charge a card, send an email) on a timeout can execute it twice if the first attempt actually succeeded server-side and only the response was lost.
- →This has no jitter: every caller retrying the same failing dependency backs off on the exact same schedule, which can cause synchronized retry storms under load. Real production backoff usually adds a random jitter factor to the delay.
- →There's no overall timeout — with the defaults, 3 retries plus their delays could still take several seconds beyond the calls themselves before giving up.
Part by part
- for (let attempt = 0; ; attempt++)
- A deliberately conditionless loop. Exit happens by returning on success or rethrowing once retries run out, which keeps both endings explicit.
- return await fn()
- The await matters: without it the promise returns before the catch can see a rejection, so failures escape the retry entirely.
- if (attempt >= retries) throw err
- Rethrows the original error rather than a wrapper, so the caller sees what actually went wrong on the final attempt.
- baseMs * 2 ** attempt
- Exponential growth — 200ms, 400ms, 800ms. Backing off gives a struggling service room to recover instead of adding to the load.
- Math.min
- Caps the delay, so a high retry count cannot produce a wait measured in minutes.
Common mistakes
const delay = baseMs * attempt;Linear growth backs off far too slowly under real load — 200ms, 400ms, 600ms barely spreads the attempts. Doubling is what actually relieves a saturated service.
const delay = Math.min(baseMs * 2 ** attempt, maxMs);return fn();Without await, the promise is returned immediately and a rejection never reaches the catch block. The retry logic is present, compiles, and never runs.
Retrying every error the same wayA 400 or a 404 will fail identically every time, so retrying wastes the caller's time and the server's. Retry timeouts, connection failures and 5xx responses; fail fast on anything the client got wrong.
Questions
Should I add jitter?
Yes, for anything with many clients. Without it every caller that failed at the same moment retries at the same moment, and the recovering service is hit by a synchronised wave. Randomising the delay spreads them out.
Which errors are worth retrying?
Transient ones: network failures, timeouts, 429 and 5xx. A 4xx other than 429 means the request itself is wrong, so repeating it unchanged cannot succeed.
How many retries is sensible?
Three is a good default. Beyond that the total wait becomes noticeable to whoever is waiting, and a service that has failed four times is usually properly down rather than briefly busy.
Does it respect a Retry-After header?
No, and it should where one is present — a 429 often tells you exactly how long to wait. Read the header and prefer it over the computed delay when the server has given you a number.