DEV Community

137Foundry
137Foundry

Posted on

How to Implement Exponential Backoff With Jitter in Any Language

A retry loop that just waits a fixed second between attempts works fine in a demo and falls apart the first time a real outage hits, because every client ends up retrying on the same clock. Exponential backoff with jitter fixes both halves of that problem: backoff spaces out your own attempts, and jitter keeps you from being synchronized with everyone else's. Here's how to build it step by step, in a way that ports cleanly to whatever language you're using.

Step 1: Start with the naive version so you can see what's missing

Before adding backoff or jitter, write the version most people reach for first.

async function retryNaive(fn, attempts = 5) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === attempts - 1) throw err;
      await sleep(1000);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Whether this runs in a browser or on Node.js, it retries five times with a flat one-second gap. It works for a single client hitting a single transient blip. It fails badly the moment a shared dependency goes down and every caller retries on the exact same one-second interval indefinitely.

That failure mode is easy to miss if you only ever test this code path in isolation. A single instance retrying against a mocked failure looks identical whether you're using a fixed delay or full exponential backoff with jitter, because the difference only shows up with many concurrent callers, which is precisely the scenario most local testing doesn't reproduce.

Step 2: Add exponential growth to the delay

Replace the fixed delay with one that doubles each attempt, capped so it doesn't grow unbounded.

function backoffDelay(attempt, base = 1000, cap = 20000) {
  return Math.min(base * 2 ** attempt, cap);
}
Enter fullscreen mode Exit fullscreen mode

Attempt zero waits one second, attempt one waits two, attempt two waits four, and so on until the cap kicks in. The cap matters as much as the growth curve. Without one, attempt eight is already waiting over four minutes, long past the point any caller is still waiting on the result.

Picking the right cap value depends on what's calling this function. A background job with no impatient human waiting can tolerate a 30 or 60 second cap comfortably. A retry loop backing a synchronous API endpoint that a user's browser is waiting on should cap much lower, often under 10 seconds total across all attempts combined, since nothing past that point helps a request the caller has probably already abandoned.

Step 3: Add jitter so parallel clients don't collide

Growth alone doesn't solve the synchronization problem. If a thousand clients failed at the same instant, they're all still on the same schedule, just a slower one. Jitter randomizes the actual wait within a range.

function backoffWithJitter(attempt, base = 1000, cap = 20000) {
  const delay = Math.min(base * 2 ** attempt, cap);
  return Math.random() * delay;
}
Enter fullscreen mode Exit fullscreen mode

This is "full jitter": pick anywhere between zero and the computed delay. It's a simpler formula than "equal jitter" (half fixed, half random) and tends to spread load at least as well in practice, which is part of why it's become the more commonly recommended default.

Step 4: Only retry errors that are actually worth retrying

A retry loop that retries everything, including a 400 Bad Request or a bug in your own code, just delays the inevitable failure while wasting time and load on the server.

function isRetryable(err) {
  if (err.status && err.status >= 400 && err.status < 500) return false;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Wire this check into the catch block so a client error fails immediately instead of retrying five times against a request that was never going to succeed. This one check eliminates a surprising fraction of unnecessary retry traffic in most systems.

It's worth being specific about which status codes actually belong in this bucket. A 401 or 403 means the request will never succeed without a different credential, so retrying is pure waste. A 408 request timeout, on the other hand, sits closer to a 5xx in practice, since it usually reflects a transient server-side condition rather than a permanently invalid request, and treating it as non-retryable can cause otherwise recoverable requests to fail outright.

Step 5: Assemble the pieces into one function

async function retryWithBackoffAndJitter(fn, attempts = 5) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === attempts - 1 || !isRetryable(err)) throw err;
      const delay = backoffWithJitter(i);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This is a complete, usable retry wrapper for a single async function. In Python, the same shape uses time.sleep and random.uniform, both documented on python.org; in Go, time.Sleep and math/rand. The logic translates directly regardless of language, which is why it's worth understanding the pattern rather than memorizing one implementation.

Step 6: Respect a Retry-After header if the server sends one

If the API you're calling returns a Retry-After header, whether from a 429 rate limit or a 503 during a deploy, use that value instead of your own computed delay. The server knows its own recovery timeline better than your client's guess does.

function delayFromResponse(res, computedDelay) {
  const retryAfter = res.headers.get("retry-after");
  return retryAfter ? Number(retryAfter) * 1000 : computedDelay;
}
Enter fullscreen mode Exit fullscreen mode

This single check often matters more for staying on good terms with a third-party API than the jitter formula does, since ignoring an explicit rate-limit signal is the fastest way to get your client throttled harder or blocked outright.

Testing it without waiting real seconds

Pass the sleep function in as a parameter rather than hardcoding setTimeout, so tests can swap in an instant no-op version and assert on the number of calls and computed delays without a multi-second test run.

async function retryTestable(fn, attempts, sleepFn = sleep) {
  // same logic, but calls sleepFn(delay) instead of setTimeout directly
}
Enter fullscreen mode Exit fullscreen mode

This one change is what separates a test suite that runs in milliseconds from one that takes minutes for no good reason, and it costs nothing beyond an extra function parameter.

It also makes the retry logic itself much easier to actually verify. With a mocked sleep function you can assert the exact sequence of delays your backoff and jitter math produced across five attempts, which is the only reliable way to catch a subtle bug, like an off-by-one in the exponent or a cap applied before jitter instead of after, before it ships and only shows up as unexpectedly aggressive retry traffic in production.

Watching this in production, not just in tests

Once this ships, add basic observability alongside it: log the attempt number and computed delay for every retry, and track a retry-rate metric per endpoint separately from your overall error rate. A dependency that's degrading gradually often gets masked by successful retries long before it shows up as a user-visible error, and a retry-rate graph is usually the earliest signal that something's starting to go wrong upstream.

Where to go from here

Exponential backoff with jitter handles the retry side of resilience well on its own, but it's only half the picture. Pairing it with a circuit breaker, so a persistently failing dependency stops getting called entirely for a cooldown period, and a hard retry budget per operation, closes the gaps a retry loop alone can't. 137Foundry's full guide to retry and backoff code snippets covers working examples in JavaScript, Python, and Go if you want to see the complete pattern including a circuit breaker implementation.

If you're building this into a client-facing product or an internal service mesh, https://137foundry.com works with engineering teams on exactly this kind of reliability work.

Top comments (0)