Retry with Exponential Backoff
Network calls and flaky integrations need a retry wrapper, but plain retries hammer the same endpoint and amplify outages. Exponential backoff doubles the wait between attempts so transient failures recover fast and persistent failures don't DDoS the upstream. This snippet covers the canonical retry, a jittered version that prevents thundering-herd retries across clients, and a policy-driven variant that lets the caller decide which errors are retryable.
855 views
15
async function retry(fn, { retries = 3, baseMs = 100 } = {}) {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastErr = err;
if (attempt === retries) break;
const wait = baseMs * 2 ** attempt;
await new Promise((r) => setTimeout(r, wait));
}
}
throw lastErr;
}
let attempts = 0;
const flaky = async () => {
attempts += 1;
if (attempts < 3) throw new Error('flake');
return 'ok';
};
retry(flaky, { retries: 5, baseMs: 5 }).then((r) => console.log('result:', r, 'attempts:', attempts));
// result: ok attempts: 3The shape is a for loop with try/catch and a sleep between attempts. Doubling the wait via baseMs * 2 ** attempt gives 100ms, 200ms, 400ms, 800ms, which lets transient blips recover quickly while persistent failures back off enough to not overwhelm upstream. The attempt === retries early break ensures the final attempt does NOT sleep needlessly before throwing. Always rethrow the last error so callers can react to genuine failures; swallowing it turns this from a retry into a silent skip.
function jitter(baseMs, attempt, max = 30000) {
const exp = Math.min(max, baseMs * 2 ** attempt);
return Math.floor(Math.random() * exp);
}
async function retryJitter(fn, { retries = 3, baseMs = 100, max = 30000 } = {}) {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastErr = err;
if (attempt === retries) break;
await new Promise((r) => setTimeout(r, jitter(baseMs, attempt, max)));
}
}
throw lastErr;
}
// Demonstrate jitter values for the same attempt across calls
console.log([0, 1, 2, 3].map((a) => jitter(50, a, 5000)));When thousands of clients all retry on the same schedule after an outage, they crash the upstream the moment it comes back. AWS calls this the "thundering herd" and the standard fix is full jitter: pick a random wait between 0 and the exponential ceiling. The Math.min(max, baseMs * 2 ** attempt) clamp prevents waits from growing past the configured ceiling (15 minutes is the AWS default), which keeps long-tail retries bounded. Use jittered backoff in any client that runs at scale; without it, retries can themselves cause an outage.
async function retryWithPolicy(fn, { retries = 3, baseMs = 100, shouldRetry = () => true } = {}) {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastErr = err;
if (attempt === retries || !shouldRetry(err, attempt)) break;
const wait = baseMs * 2 ** attempt;
await new Promise((r) => setTimeout(r, wait));
}
}
throw lastErr;
}
const networkError = (msg) => Object.assign(new Error(msg), { code: 'NET' });
const businessError = (msg) => Object.assign(new Error(msg), { code: 'VALIDATION' });
let n = 0;
const sometimesFails = async () => {
n += 1;
if (n === 1) throw networkError('blip');
if (n === 2) throw businessError('bad input'); // do NOT retry
return 'never reached';
};
retryWithPolicy(sometimesFails, {
baseMs: 5,
shouldRetry: (err) => err.code === 'NET',
}).catch((err) => console.log('stopped on:', err.code, 'after', n, 'tries'));
// stopped on: VALIDATION after 2 triesRetrying every error is wrong: a 400 "invalid email" error will keep failing forever, just slower. A shouldRetry(err, attempt) predicate lets the caller distinguish transient errors (timeouts, 5xx, ECONNRESET) from permanent ones (4xx, validation, auth). The default () => true keeps the simple case short while leaving the door open for real services to plug in their own policy. The same hook can also implement a maximum elapsed wall-clock time by recording a start timestamp in the closure and returning false once it's exceeded.
