When a request fails for a reason that may pass, such as a dropped connection or an overloaded server, trying again a little later often works. Backoff means waiting before each new attempt. Exponential backoff means the wait is multiplied by the same factor after every failure: with a first wait of 100 ms and a factor of 2, you wait 100 ms, then 200, then 400, then 800.
Why make the waits grow? Retrying straight away, again and again, hammers a server that's already struggling, and when thousands of clients do it at once, they keep it down. Growing waits retry quickly when the failure was a blip, and back off further and further when it isn't, which gives the server room to recover. It's how you'd redial a busy number: after a minute, then two, then four, not nonstop.
Here are five attempts with a first wait of 500 ms and a factor of 2, where every attempt fails straight away:
| Attempt | Wait before it | Starts at |
|---|---|---|
| 1 | None | 0 ms |
| 2 | 500 ms | 500 ms |
| 3 | 1000 ms | 1500 ms |
| 4 | 2000 ms | 3500 ms |
| 5 | 4000 ms | 7500 ms |
With a fixed 500 ms wait instead, the same five attempts would all be over by 2000 ms. If the last attempt fails too, you stop and report its error rather than keep the user waiting forever.
Retry only failures that might go away: a network error, a timeout, or a response such as 503 Service Unavailable or 429 Too Many Requests. A 404 Not Found fails the same way every time. Real retry code also often caps the wait and adds jitter, a random variation in each wait, so that many clients that failed at the same moment don't all retry at the same moment too.
You meet it in data-fetching libraries. TanStack Query, for example, retries a failed query 3 times by default in the browser, waiting 1000 ms, then 2000, then 4000, and never more than 30 seconds.
Write retry(fn, options). It calls fn(attempt), with attempt starting at 1, and returns a promise. If the call succeeds, the promise fulfills with its result.
If the call fails (fn throws, or its promise rejects), retry waits, then calls fn again, up to retries more times. If every attempt fails, the promise rejects with the last error, as soon as the last attempt fails.
The wait grows exponentially: delay ms before the second attempt, then delay * factor, then delay * factor ** 2, and so on.
options is optional, and so is each of its fields, { retries, delay, factor }. A missing retries is 3, delay is 100, and factor is 2. So by default retry makes up to 4 calls, at 0, 100, 300, and 700 ms. retries: 0 means a single call.
const data = await retry((attempt) => fetchJson("/api/flaky"), { retries: 4, delay: 200 });
// tries at 0, 200, 600, 1400, and 3000 ms, then gives up with the last error
useFakeTimers();
const fn = spy(async () => "ok");
let result;
retry(fn).then((value) => {
result = value;
});
await flush();
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
const clock = useFakeTimers();
const fn = spy(async (attempt) => {
if (attempt < 3) throw new Error("fail " + attempt);
return "ok on " + attempt;
});
let result;
retry(fn, { retries: 5, delay: 10 }).then((value) => {
result = value;
});
await clock.tickAsync(1000);
expect(fn.calls.map((call) => call.args[0])).toEqual([1, 2, 3]);
expect(result).toBe("ok on 3");
const clock = useFakeTimers();
const start = clock.now();
const calledAt = [];
const fn = async () => {
calledAt.push(clock.now() - start);
throw new Error("down");
};
retry(fn, { retries: 3, delay: 100 }).catch(() => {}); // every attempt fails; this test only checks when they ran
await clock.tickAsync(1000);
expect(calledAt).toEqual([0, 100, 300, 700]);
const clock = useFakeTimers();
const start = clock.now();
const calledAt = [];
const fn = async () => {
calledAt.push(clock.now() - start);
throw new Error("down");
};
retry(fn, { retries: 3, delay: 10, factor: 3 }).catch(() => {}); // every attempt fails; this test only checks when they ran
await clock.tickAsync(1000);
expect(calledAt).toEqual([0, 10, 40, 130]);
const clock = useFakeTimers();
const fn = spy(async (attempt) => {
throw new Error("fail " + attempt);
});
let reason;
retry(fn, { retries: 2, delay: 50 }).catch((e) => {
reason = e;
});
await clock.tickAsync(150);
expect(fn).toHaveBeenCalledTimes(3);
expect(reason).toBeInstanceOf(Error);
expect(reason.message).toBe("fail 3");
const clock = useFakeTimers();
let calls = 0;
const fn = () => {
calls += 1;
if (calls === 1) throw new Error("not ready");
return "ready";
};
let result;
retry(fn, { delay: 10 }).then((value) => {
result = value;
});
await clock.tickAsync(10);
expect(result).toBe("ready");
const clock = useFakeTimers();
const error = new Error("nope");
const fn = spy(async () => {
throw error;
});
let reason;
retry(fn, { retries: 0 }).catch((e) => {
reason = e;
});
await clock.tickAsync(1000);
expect(fn).toHaveBeenCalledTimes(1);
expect(reason).toBe(error);
const clock = useFakeTimers();
const start = clock.now();
const calledAt = [];
retry(() => {
calledAt.push(clock.now() - start);
throw new Error("down");
}).catch(() => {}); // every attempt fails; this test only checks when they ran
await clock.tickAsync(2000);
expect(calledAt).toEqual([0, 100, 300, 700]);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Retry with exponential backoff. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.