Retry Strategies: Exponential Backoff and Jitter
A network call fails. The obvious fix is "just retry it" — but retrying naively is how a brief blip turns into a full outage, because every client hammers the recovering service at the exact same moment. Exponential backoff with jitter is the standard fix, and it's simple enough to implement in about fifteen lines once you understand why each piece is there.
Why naive retries make things worse
Say a downstream service goes down for two seconds under load. A thousand clients all get a failed request, and all of them retry immediately. The service, just barely coming back up, gets hit with the same thousand requests again at once — plus whatever new traffic arrived in the meantime. It falls back over. This is a retry storm, and it's a common way a small, transient blip becomes a prolonged outage.
Two problems need fixing: retries happening too soon, and retries happening in lockstep across clients.
Exponential backoff: space retries out
Instead of retrying immediately, wait longer after each failure — typically doubling the delay each time.
async function withBackoff(fn, { maxRetries = 5, baseDelayMs = 200, maxDelayMs = 10000 } = {}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
if (attempt > maxRetries) throw err;
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
With baseDelayMs: 200, the delays are roughly 200ms, 400ms, 800ms, 1600ms, 3200ms — growing fast, capped by maxDelayMs so a flaky dependency doesn't leave a request waiting minutes. This alone fixes "retrying too soon." It does nothing for "retrying in lockstep," because every client computes the exact same delay sequence.
Jitter: break the lockstep
If every client backs off by exactly 200ms, then exactly 400ms, they're still all retrying at the same instant relative to each other — just less often. Jitter adds randomness to the delay so retries spread out over time instead of arriving in synchronized waves.
function backoffWithFullJitter(attempt, baseDelayMs, maxDelayMs) {
const cap = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
return Math.random() * cap;
}
This is "full jitter" — the delay is a random value between 0 and the exponential cap, rather than the cap itself. AWS's architecture blog popularized this approach after finding it outperformed both plain exponential backoff and "equal jitter" (cap/2 plus a random fraction of cap/2) under high contention, precisely because it spreads retries across the widest window.
Wiring it into the retry loop:
async function withBackoffAndJitter(fn, { maxRetries = 5, baseDelayMs = 200, maxDelayMs = 10000 } = {}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
if (attempt > maxRetries) throw err;
const delay = backoffWithFullJitter(attempt - 1, baseDelayMs, maxDelayMs);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
Not every failure deserves a retry
Retrying blindly on any error is its own bug. A 400 Bad Request or a 401 Unauthorized will fail identically no matter how many times you retry it — you're just adding load for nothing. Only retry failures that are plausibly transient:
- Network-level errors (connection reset, timeout)
429 Too Many Requests502,503,504— gateway/upstream failures
function isRetryable(err) {
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') return true;
if (err.status && [429, 502, 503, 504].includes(err.status)) return true;
return false;
}
Check this before backing off, and re-throw immediately for anything else:
} catch (err) {
if (!isRetryable(err)) throw err;
// ...backoff logic
}
Respect Retry-After
A 429 or 503 response often comes with a Retry-After header telling you exactly how long to wait. When it's present, use it instead of your own computed delay — the server knows its own recovery time better than your guess does.
function delayFromResponse(err, fallbackDelayMs) {
const retryAfter = err.headers?.['retry-after'];
if (!retryAfter) return fallbackDelayMs;
const seconds = Number(retryAfter);
return Number.isFinite(seconds) ? seconds * 1000 : fallbackDelayMs;
}
Cap the total retry budget, not just the delay
maxRetries alone can still let a single logical operation take a long time if the caps are generous — five retries with a 10-second max delay is potentially 30+ seconds spent retrying one call. If the caller has its own timeout (a user waiting on a request, a queue consumer with a visibility timeout), track elapsed time and bail out early:
async function withDeadline(fn, deadlineMs) {
const start = Date.now();
return withBackoffAndJitter(async () => {
if (Date.now() - start > deadlineMs) {
throw new Error('retry deadline exceeded');
}
return fn();
});
}
Takeaway
Exponential backoff fixes retries that happen too soon. Jitter fixes retries that happen in sync across clients. Neither replaces checking whether a failure is worth retrying at all, and neither should ignore a Retry-After header when the server bothers to send one. Most HTTP client libraries (axios interceptors, got's retry option, resilience libraries like cockatiel or polly in .NET) implement all of this already — reach for one before hand-rolling it in production code, but know what it's doing under the hood so you can tell when the defaults don't fit your situation.