Retry With Backoff and Jitter: The Version I Copy Everywhere
Naive retries hammer a recovering server and turn blips into outages Full jitter spreads retries so clients stop firing in sync Retry 429 and 503, never 400 or 401, and respect idempotency A 20-line function I paste into
Naive retries hammer a recovering server and turn blips into outages
Full jitter spreads retries so clients stop firing in sync
Retry 429 and 503, never 400 or 401, and respect idempotency
A 20-line function I paste into every project, copy it
A single failed API call shouldn't take down your backend. But the naive retry loop most people write does exactly that, because it waits the same amount of time on every client and fires them all back at once. Here's the version I copy into every project instead.
Why naive retries make outages worse
Picture a service that handles a thousand requests a second. Something hiccups for two seconds. A load balancer blips, a database connection pool fills up, whatever. During those two seconds, maybe two thousand requests fail.
Now every one of those clients retries. If they all retry immediately, you get two thousand extra requests the instant the service comes back. On top of the normal thousand per second. The service that was already struggling now gets hit with triple the load at the worst possible moment.
That's a retry storm. The retries cause the outage to keep going.
I've seen this pattern play out with fixed-delay retries too. "Wait one second, then try again" sounds safe. But if all your clients failed at the same moment, they all wait one second, and they all come back at the same moment. You've just moved the stampede one second into the future. You didn't spread it out.
The naive loop looks reasonable on paper:
for (let i = 0; i < 5; i++) {
try {
return await call();
} catch (e) {
await sleep(1000);
}
}
Five tries, one second apart. Fine for one client sitting alone. Terrible when ten thousand clients run the identical loop and fail together.
The fix has two parts. First, back off more each time so you're not pounding a recovering service. Second, add randomness so clients spread out instead of syncing up. The first part is exponential backoff. The second part is jitter. People remember the first and forget the second, and the second is the one that actually saves you.
Backoff alone still clusters. If everyone uses delay = base * 2^attempt, then everyone's third retry lands at the same tick. You need the random offset to break the alignment. Think of it as politeness from each client toward a server that's having a bad day. If you want to see the failures clearly while this is happening, you'll want logging in place first. I covered this in more depth at Structured Logging I Add on Day One of Every Project.
Full jitter, and why I don't use the other kinds
There are a few flavors of jitter. I'll save you the comparison I did and tell you the winner: full jitter.
With full jitter, you compute the exponential ceiling, then pick a random delay between zero and that ceiling. So on attempt three with a 200ms base, the ceiling might be 1600ms, and you sleep somewhere between 0 and 1600ms. Every client picks its own number. They scatter across the whole window instead of bunching at the top.
The formula is short:
cap = min(maxDelay, base * 2^attempt)
delay = random(0, cap)
Compare that to "equal jitter," where you sleep half the ceiling plus a random half. That still keeps a floor under every client, so they're less spread out. Full jitter uses the whole range and spreads the hardest.
The counterintuitive part: picking a delay that can be as low as zero feels wrong. Won't some clients retry almost instantly? Yes, a few will. But only a few, because the random pick is uniform across the window. The rest land all over the place. The total load at any single instant stays low, which is the entire point.
I cap the maximum delay too. Without a cap, 2^attempt grows fast, and by attempt ten you're waiting 200 seconds. A 30-second cap is usually sensible. I also cap the number of attempts, because retrying forever just hides a real problem. Four or five attempts is my default.
One more thing: measure before you tune these numbers. Base delay, cap, and attempt count all interact. The habit of counting actual calls instead of guessing is the same one I used in Debounce vs Throttle: I Counted Every Call. Don't pick 2^attempt because it's traditional. Pick it because you watched the timing and it spread your load the way you needed.
If your clients are all on cron jobs that fire on the minute, jitter matters even more. Without it, every scheduled job hits your API on the exact same second, every minute, forever.
Which errors deserve a retry, and which never do
Retrying the wrong error is worse than not retrying at all. You burn attempts, you add latency, and sometimes you do real damage.
Retry these: HTTP 429 (too many requests), 503 (service unavailable), 502 and 504 (gateway errors), and network-level failures like a connection reset or a timeout. These are temporary. The server is busy or briefly unreachable, and a later attempt has a real chance of working.
Never retry these: 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (not found), and 422 (unprocessable). These are your fault, not a transient blip. Your payload is malformed, your token is wrong, or the thing doesn't exist. Retrying sends the identical broken request again and gets the identical rejection. You just wasted five round trips to confirm what the first one told you.
For 429 specifically, check for a Retry-After header. If the server tells you exactly when to come back, use that number instead of your computed backoff. The server knows better than your formula does.
Then there's idempotency, which is the part people skip and regret. A GET is safe to retry; you're just reading. A PUT or DELETE is usually safe because repeating it lands you in the same state. A POST is the dangerous one. If you POST "create order," time out before the response, and retry, you might create two orders. The first one may have succeeded and you never saw the reply.
So for non-idempotent writes, either don't retry, or send an idempotency key so the server deduplicates. Most payment and order APIs support a key header exactly for this. Set it once per logical operation and reuse it across retries.
I keep a tiny classifier function that answers one question: is this error retryable? It checks the status code against the retry set, checks for network error types, and returns a boolean. Keeping that logic in one place means I never second-guess it at the call site.
Timeouts are the subtle case. A timeout on a read is retryable. A timeout on a write might mean the write already happened. Treat write timeouts with the same caution as a POST. If you want to understand the failure modes that hide behind a simple-looking call, I Found the Exact Recursion Depth That Crashes Node is a good companion read.
The function I actually paste
Here's the whole thing. It's short on purpose. I'd rather read twenty lines than trust a dependency for this.
const RETRYABLE = new Set([429, 502, 503, 504]);
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function retry(fn, opts = {}) {
const base = opts.base ?? 200;
const cap = opts.cap ?? 30000;
const max = opts.max ?? 5;
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
const status = err.status ?? err.response?.status;
const retryable =
RETRYABLE.has(status) || err.code === "ECONNRESET";
if (!retryable || attempt >= max) throw err;
const ceiling = Math.min(cap, base * 2 ** attempt);
const delay = Math.floor(Math.random() * ceiling);
await sleep(delay);
attempt++;
}
}
}
That's it. Base delay, a cap, a max attempt count, full jitter on every wait. You call it like await retry(() => fetch(url)) and it handles the rest.
A few notes on using it. Pass your own retryable check when you need one; the default set covers most HTTP cases, but a database driver throws different error shapes. I usually add an onRetry callback so I can log each attempt with the delay it chose, which makes retry storms obvious in the logs when they happen.
For a Retry-After header, I read it before computing the jitter and clamp my delay to at least that value. The server's instruction wins.
Don't wrap this around non-idempotent writes without an idempotency key. The function doesn't know your POST is dangerous. That judgment stays with you at the call site.
I keep the retryable set small and explicit on purpose. It's tempting to retry on any 5xx, but a 500 is often a genuine bug on the server that won't fix itself in 400ms. I'd rather fail fast and see it than mask it behind four quiet retries.
This same instinct (small, boring, reusable building blocks I trust) is how I build everything at the studio. The full approach is in the Claude Blueprint. It's the same idea applied to a whole workflow instead of one function.
Bottom Line
Copy the function above into your next project and tune three numbers: base delay, cap, and max attempts. Start with 200ms, 30 seconds, five tries, then watch your logs and adjust once you see real failures.
The single change that matters most is the jitter line. Math.random() * ceiling instead of a fixed wait is what stops your clients from stampeding a server that's already down. Everything else is detail.
And before you wrap any write in a retry, ask whether running it twice is safe. If it isn't, add an idempotency key or leave the retry off. A missed error you can see beats a duplicate order you can't.
If you want the broader way I think about resilient, boring code that keeps working when I'm not looking at it, the Claude Blueprint walks through the whole setup. Grab the function, ship it today, and your next outage stays a blip instead of becoming the story.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.