Retrying a failed request is common; retrying it well is the trick. Exponential backoff re-runs a failing async function with a delay that grows after each attempt — wait 100ms, then 200ms, then 400ms — so a struggling server gets breathing room instead of a hammering. It's the standard resilience pattern for network calls.
Implement retry(fn, options). Call fn; if it rejects, wait and retry, multiplying the delay by factor each time, up to retries attempts. If it still fails, reject with the last error.
function retry(fn, options) {
// options: { retries = 3, delay = 100, factor = 2 }
// returns a promise that resolves with fn's value, or rejects with the
// last error after retries are exhausted.
}
// Retries up to 3 times, waiting 100ms, 200ms, 400ms between attempts:
await retry(() => fetch('/flaky'));
await retry(fetchData, { retries: 5, delay: 50, factor: 2 });
// waits 50, 100, 200, 400, 800 ms between the 5 retries
retries is the retry count — after the first attempt. So retries: 3 means up to 4 total calls (1 + 3).factor — first wait is delay, then delay * factor, then delay * factor², and so on.fn resolves; return its value.factor of 1 — keeps the delay constant (linear retry); the default 2 doubles it each time.You'll wrap an async function so that when it fails, it tries again after a pause — and each pause is longer than the last.
Network calls fail transiently: a server hiccups, a connection drops. Often the fix is simply to try again. But retrying immediately, over and over, piles more load onto whatever is already struggling. Exponential backoff spaces the retries out and widens the gap each time — 100ms, then 200ms, then 400ms — giving the failing service room to recover. You're building retry(fn, options).
Try the function. If it resolves, you're done — resolve with its value. If it rejects and you still have retries left, wait the current delay, multiply the delay by factor for next time, and try again. If it rejects with no retries left, give up and reject with that last error.
The naive recursive retry drops the delay entirely:
async function retryNaive(fn, retries = 3) {
try {
return await fn();
} catch (err) {
if (retries === 0) throw err;
return retryNaive(fn, retries - 1); // retries immediately
}
}
It does retry the right number of times, but it retries instantly — no pause at all. Against a rate-limited or overloaded server, that's the worst thing you can do: you hammer it with rapid-fire requests exactly when it's least able to cope, and you're likely to keep getting the same failure. The whole value of the pattern is the growing wait, which this version has none of.
function retry(fn, options = {}) {
const { retries = 3, delay = 100, factor = 2 } = options;
return new Promise((resolve, reject) => {
let attemptsLeft = retries;
let wait = delay;
const run = () => {
// Promise.resolve().then(fn) normalizes fn — even a synchronous throw
// becomes a rejection we can catch.
Promise.resolve()
.then(fn)
.then(resolve) // success: stop and resolve with the value
.catch((err) => {
if (attemptsLeft === 0) {
reject(err); // out of retries: reject with the LAST error
return;
}
attemptsLeft -= 1;
setTimeout(run, wait); // wait the current delay, then retry
wait *= factor; // grow the delay for the next retry
});
};
run();
});
}
module.exports = { retry };
The run function is the retry loop expressed through the promise chain. On success, .then(resolve) ends it. On failure, the .catch checks the budget: if attemptsLeft is 0, reject with the current error (the last one seen); otherwise decrement the budget, schedule the next run after wait milliseconds, and multiply wait by factor so the next gap is larger. Promise.resolve().then(fn) wraps the call so a synchronous throw is caught alongside a rejected promise. The delay sequence is delay, delay * factor, delay * factor², … — the exponential backoff.
Take retry(fn, { retries: 2, delay: 20, factor: 2 }) where fn fails twice then succeeds:
fn() rejects. attemptsLeft is 2 (not 0) → decrement to 1, setTimeout(run, 20), wait becomes 40.fn() rejects again. attemptsLeft is 1 → decrement to 0, setTimeout(run, 40), wait becomes 80.fn() resolves → resolve(value). Done.Three attempts total (1 initial + 2 retries), with waits of 20ms then 40ms — the second gap double the first. Had fn failed a third time, attemptsLeft would be 0 and it would reject with that final error.
setTimeout(run, wait) is the entire point; don't drop it.retries — retries counts retries, not total attempts. retries: 3 allows 4 calls. Decide this explicitly and match the docs.wait by factor for the next retry, so the first retry uses the base delay, not delay * factor.wait * (0.5 + Math.random())) prevents the "thundering herd" where many clients retry in lockstep and re-overload the server at the same instant.shouldRetry(err) predicate lets you retry on a 503 but fail fast on a 400, since retrying a bad request never helps.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Retrying a failed request is common; retrying it well is the trick. Exponential backoff re-runs a failing async function with a delay that grows after each attempt — wait 100ms, then 200ms, then 400ms — so a struggling server gets breathing room instead of a hammering. It's the standard resilience pattern for network calls.
Implement retry(fn, options). Call fn; if it rejects, wait and retry, multiplying the delay by factor each time, up to retries attempts. If it still fails, reject with the last error.
function retry(fn, options) {
// options: { retries = 3, delay = 100, factor = 2 }
// returns a promise that resolves with fn's value, or rejects with the
// last error after retries are exhausted.
}
// Retries up to 3 times, waiting 100ms, 200ms, 400ms between attempts:
await retry(() => fetch('/flaky'));
await retry(fetchData, { retries: 5, delay: 50, factor: 2 });
// waits 50, 100, 200, 400, 800 ms between the 5 retries
retries is the retry count — after the first attempt. So retries: 3 means up to 4 total calls (1 + 3).factor — first wait is delay, then delay * factor, then delay * factor², and so on.fn resolves; return its value.factor of 1 — keeps the delay constant (linear retry); the default 2 doubles it each time.You'll wrap an async function so that when it fails, it tries again after a pause — and each pause is longer than the last.
Network calls fail transiently: a server hiccups, a connection drops. Often the fix is simply to try again. But retrying immediately, over and over, piles more load onto whatever is already struggling. Exponential backoff spaces the retries out and widens the gap each time — 100ms, then 200ms, then 400ms — giving the failing service room to recover. You're building retry(fn, options).
Try the function. If it resolves, you're done — resolve with its value. If it rejects and you still have retries left, wait the current delay, multiply the delay by factor for next time, and try again. If it rejects with no retries left, give up and reject with that last error.
The naive recursive retry drops the delay entirely:
async function retryNaive(fn, retries = 3) {
try {
return await fn();
} catch (err) {
if (retries === 0) throw err;
return retryNaive(fn, retries - 1); // retries immediately
}
}
It does retry the right number of times, but it retries instantly — no pause at all. Against a rate-limited or overloaded server, that's the worst thing you can do: you hammer it with rapid-fire requests exactly when it's least able to cope, and you're likely to keep getting the same failure. The whole value of the pattern is the growing wait, which this version has none of.
function retry(fn, options = {}) {
const { retries = 3, delay = 100, factor = 2 } = options;
return new Promise((resolve, reject) => {
let attemptsLeft = retries;
let wait = delay;
const run = () => {
// Promise.resolve().then(fn) normalizes fn — even a synchronous throw
// becomes a rejection we can catch.
Promise.resolve()
.then(fn)
.then(resolve) // success: stop and resolve with the value
.catch((err) => {
if (attemptsLeft === 0) {
reject(err); // out of retries: reject with the LAST error
return;
}
attemptsLeft -= 1;
setTimeout(run, wait); // wait the current delay, then retry
wait *= factor; // grow the delay for the next retry
});
};
run();
});
}
module.exports = { retry };
The run function is the retry loop expressed through the promise chain. On success, .then(resolve) ends it. On failure, the .catch checks the budget: if attemptsLeft is 0, reject with the current error (the last one seen); otherwise decrement the budget, schedule the next run after wait milliseconds, and multiply wait by factor so the next gap is larger. Promise.resolve().then(fn) wraps the call so a synchronous throw is caught alongside a rejected promise. The delay sequence is delay, delay * factor, delay * factor², … — the exponential backoff.
Take retry(fn, { retries: 2, delay: 20, factor: 2 }) where fn fails twice then succeeds:
fn() rejects. attemptsLeft is 2 (not 0) → decrement to 1, setTimeout(run, 20), wait becomes 40.fn() rejects again. attemptsLeft is 1 → decrement to 0, setTimeout(run, 40), wait becomes 80.fn() resolves → resolve(value). Done.Three attempts total (1 initial + 2 retries), with waits of 20ms then 40ms — the second gap double the first. Had fn failed a third time, attemptsLeft would be 0 and it would reject with that final error.
setTimeout(run, wait) is the entire point; don't drop it.retries — retries counts retries, not total attempts. retries: 3 allows 4 calls. Decide this explicitly and match the docs.wait by factor for the next retry, so the first retry uses the base delay, not delay * factor.wait * (0.5 + Math.random())) prevents the "thundering herd" where many clients retry in lockstep and re-overload the server at the same instant.shouldRetry(err) predicate lets you retry on a 503 but fail fast on a 400, since retrying a bad request never helps.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.