[KongchangAI]
· 3 min read· 1,825 words

Retry Mechanisms in Practice: Exponential Backoff + Jitter, Avoiding Retry Avalanches with One Line of Code

Retry Mechanisms in Practice: Exponential Backoff + Jitter, Avoiding Retry Avalanches with One Line of Code

Fixed-delay retries amplify transient failures into storms; exponential backoff plus full jitter, retrying only recoverable errors, is the answer

The article argues that naive retries are an outage amplifier: a two-second service hiccup produces thousands of failed requests, and if clients retry immediately or with fixed delay, the recovery moment gets knocked down again by multiplied traffic. The fix requires two parts—exponential backoff lengthens a single client's cadence, and jitter staggers multiple clients' retry moments, with jitter being the half that truly saves you. The author recommends full jitter, i.e., a uniform random delay between 0 and the exponential ceiling, spreading more widely than equal jitter, while capping max delay (e.g., 30s) and attempts (four to five). On error classification, only retry 429/502/503/504 and network-layer failures, never 400/401/403/404/422, and honor the server's Retry-After header. Non-idempotent writes, especially POST, should either not be retried or carry an idempotency key; write timeouts need equal caution. It ends with a zero-dependency ~20-line retry function and suggests an onRetry hook to surface retries in logs.

Why Naive Retries Turn Small Failures into Major Outages

A service handling a thousand requests per second suddenly hiccups for two seconds—a load balancer blip, a saturated database connection pool, whatever the cause. During those two seconds, two thousand requests fail. If every client immediately retries, the moment the service recovers, two thousand extra requests flood in. A service already on the brink gets hit with three times the traffic at the worst possible moment. This is a retry storm: the retries themselves prolong the outage.

Fixed-delay retries won't save you either. "Wait one second and try again" sounds sensible, but if all clients fail at the same instant, they all wait the same instant, and then they all come back at the same instant—you've merely postponed the stampede by one second, not spread it out.

for (let i = 0; i < 5; i++) {
  try {
    return await call();
  } catch (e) {
    await sleep(1000);
  }
}

Five attempts, one second apart—fine for a single client. But when ten thousand clients run the same loop and fail simultaneously, it's a disaster. The fix requires two parts: exponential backoff so you don't keep hammering a recovering service, and jitter so clients spread out instead of synchronizing. People remember the former, forget the latter—and the latter is the half that actually saves you. Backoff alone still clusters: if everyone uses delay = base * 2^attempt, everyone's third retry lands at the same instant. You need random offset to break the alignment.

rss source: Retry With Backoff and Jitter: The Version I Copy Everywhere

The destructive power of a retry storm comes from synchronization: when a failure occurs, hundreds or thousands of clients fail at nearly the same instant, and if their retry policies are identical, they all come back at the same instant. This is essentially a thundering herd problem—the service's recovery window is choked off by its own clients, load goes up instead of down, creating positive feedback. More insidious is cascading failure: a downstream service crushed by retries starts rejecting its own downstream requests, spreading a local hiccup into a cross-service avalanche. Understanding this makes clear that the core goal of a retry policy is not "improve single-attempt success rate" but "control the total load curve during failure." Exponential backoff lengthens a single client's retry cadence; jitter staggers multiple clients' cadences—both are indispensable.

Full Jitter: Why I Don't Use Other Jitter Schemes

There are several schools of jitter, and I'll just tell you the winner: full jitter.

Full jitter works by first computing the exponential ceiling, then picking a random delay within that ceiling. The formula is short:

cap = min(maxDelay, base * 2^attempt)
delay = random(0, cap)

With a 200ms base and the third attempt, the ceiling is 1600ms, so you sleep somewhere between 0 and 1600ms. Each client picks its own number, and they scatter across the entire window instead of crowding at the top. Compare "equal jitter"—sleep half the ceiling plus a random half—which preserves a floor for each client, so it doesn't spread widely enough. Full jitter uses the entire range, spreading the load most aggressively.

The counterintuitive part: allowing delays as low as zero feels wrong—won't some clients retry almost immediately? Yes, but only a few, because the random value is uniformly distributed across the whole window, and the rest land all over. The total load at any instant stays low, which is the point.

I also cap the maximum delay. Without a cap, 2^attempt grows fast—by the tenth attempt it's 200 seconds. A 30-second cap is usually reasonable. Attempt count should be capped too, because infinite retries just hide the real problem. My default is four to five. Also, measure before tuning these numbers—base, cap, and attempt count interact, so don't pick 2^attempt because "that's tradition," pick it because you've observed the timing and it spreads load the way you need.

If your clients are all cron jobs triggering on the hour, jitter matters even more. Without it, every scheduled task hammers your API at the same second of every minute, forever.

Which Errors Are Worth Retrying, Which Never Are

Retrying the wrong errors is worse than not retrying at all. You waste attempts, add latency, and sometimes cause real damage.

Should retry: HTTP 429 (Too Many Requests), 503 (Service Unavailable), 502 and 504 (gateway errors), plus network-layer failures like connection resets and timeouts. These are transient, and retrying later has a genuine chance of success.

Never retry: 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404 (Not Found), 422 (Unprocessable Entity). These are your problem, not transient hiccups—malformed payload, bad token, thing doesn't exist. Retrying just sends the same broken request again and gets the same rejection. You've wasted five round trips to confirm what the first one already told you.

For 429, check the Retry-After header. If the server explicitly tells you when to come back, use that number instead of your computed backoff—the server knows better than your formula.

Idempotency: The Most Skipped, Most Regretted Part

GET retries are safe; you're just reading. PUT or DELETE is usually safe too, since repeated execution lands on the same state. POST is the dangerous one. If you POST "create order," time out before the response returns, then retry, you may create two orders—the first may have succeeded, you just didn't see the reply.

So for non-idempotent writes, either don't retry, or carry an idempotency key so the server can deduplicate. Most payment and order APIs support a key header specifically for this: set it once per logical operation, reuse it across retries.

Timeouts are a subtle case. Read timeouts are retryable; a write timeout may mean the write already happened. Treat write timeouts with the same caution as POST.

An idempotency key is a client-generated unique identifier (usually a UUID) sent with the request, which the server uses to determine whether "this logical operation has already been processed." On first receipt it executes normally and caches the result; if the same key arrives again, it returns the cached result without re-executing. The key must remain constant across the entire retry chain—all attempts of the same logical operation reuse the same value, and only a new logical operation generates a new key. Different vendors call it different things: Stripe uses the Idempotency-Key header, some services use a request_id in the body. Without an idempotency key, a fallback approach is "query before write," but it's unreliable under concurrency and adds a round trip. The most robust approach remains server-side deduplication.

The Function I Actually Paste

The whole thing is like this—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++;
    }
  }
}

Usage is just await retry(() => fetch(url)), and it handles the rest.

A few usage notes. Pass in your own retryable check when needed: the default set covers most HTTP scenarios, but database drivers throw differently shaped errors. I usually add an onRetry callback to log each attempt and its chosen delay—when a retry storm really happens, it's obvious in the logs.

When hitting a Retry-After header, I read it before computing jitter and clamp the delay to no less than that value—the server's instruction takes priority.

Don't wrap this function 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 deliberately keep the retryable set small and explicit. Retrying all 5xx is tempting, but 500 is often a real server-side bug that won't fix itself in 400 milliseconds. I'd rather fail fast and see it than hide it behind four quiet retries.

There are several easily overlooked details in this implementation. First, the check attempt >= max before throwing means the actual number of attempts is max retries plus the initial call, totaling max + 1—easy to be off by one when tuning. Second, Math.random() * ceiling generates a half-open interval [0, ceiling), which naturally never hits the ceiling itself; statistically harmless, but if you need strict coverage of the entire window you can change it to Math.random() * (ceiling + 1). Third, err.status ?? err.response?.status accommodates both native fetch's Response.status and axios-style err.response.status, but if you use another HTTP client, the error structure may hang off err.cause or a custom field, requiring your own adaptation. Fourth, replacing recursion with a while (true) loop avoids call stack buildup on deep retries and makes it easier to record attempt counts uniformly outside the loop.

The Bottom Line

Copy the function above into your next project, then tune three numbers: base delay, cap, and max attempts. Start with 200ms, 30 seconds, and five attempts, then watch the logs and adjust after seeing real failures.

The single most important change is the jitter line: using Math.random() * ceiling instead of a fixed wait is the key to stopping your clients from stampeding a server that's already down. Everything else is detail.

Before wrapping any write in a retry, ask: is it safe to run twice? If not, add an idempotency key, or just turn retries off. A missing error you can see beats a duplicate order you can't find.

One-line Summary

Fixed-delay retries amplify transient failures into storms; exponential backoff plus full jitter, retrying only recoverable errors, is the right approach

Paragraph Summary

The article argues that naive retries are an outage amplifier: a two-second service hiccup produces two thousand failed requests, and if clients retry immediately or with fixed delay, the recovery moment gets knocked down again by several times the traffic. The fix requires two parts—exponential backoff lengthens a single client's cadence, and jitter staggers multiple clients' retry moments, and jitter is the half that truly saves you. The author recommends full jitter, taking a uniform random delay between 0 and the exponential ceiling, which spreads more widely than equal jitter; also cap the maximum delay (e.g., 30 seconds) and attempt count (four to five). On error classification, only retry 429/502/503/504 and network-layer failures, never retry 400/401/403/404/422, and prioritize the server's Retry-After header. Non-idempotent writes, especially POST, should either not be retried or carry an idempotency key for deduplication; write timeouts need equal caution. The article closes with a roughly twenty-line zero-dependency retry function and suggests an onRetry callback to expose retries in logs.

#retry mechanism#exponential backoff#jitter#retry storm#idempotency key#HTTP 429#full jitter
Share:

Related articles