B Ben Moataz
← Back to writing operations

Retry with Exponential Backoff and Jitter (and the Retry Budget Nobody Sets)

Exponential backoff caps how fast a client retries; jitter stops every client retrying together. Here's the retry logic I actually ship, and the two limits most teams miss.

Professional headshot of Ben Moataz Ben Moataz · July 28, 2026 · 13 min read · Updated Jul 28, 2026

Retrying with exponential backoff means each failed attempt waits longer than the last — 1s, 2s, 4s, 8s — up to a cap, so a struggling dependency gets progressively more room to recover. Jitter randomizes those delays so that a thousand clients that failed at the same instant don’t all come back at the same instant. You need both: backoff limits how much load a single client generates during an outage, and jitter fixes the shape of that load across all your clients. Neither one, on its own, prevents the failure mode that actually takes systems down.

The short answer

Retry only failures that a retry can plausibly fix. Compute the delay as min(cap, base * 2^attempt), then apply full jitter — pick a random value between zero and that delay. Bound the whole operation with a deadline rather than an attempt count, honour Retry-After when the server sends one, and cap retries globally as a percentage of your normal request volume so that a total outage can’t turn your client fleet into a load generator. The formula is the easy part; the budget and the classification are what keep it from making things worse.

Why naive retries make an outage worse

Here’s the failure I get called in for. A dependency slows down. Every caller times out. Every caller immediately retries. The dependency, which was struggling under normal load, is now receiving double the normal load — then triple, as the retries themselves time out and retry again. What began as a latency blip becomes a hard outage, and it stays down after the original cause has passed, because the retry traffic alone is enough to keep it saturated.

That’s a retry storm, and its defining property is that it is self-sustaining. The system can’t recover during the outage because recovery requires a moment of reduced load, and retries guarantee there is never one. I have watched teams restart a healthy service repeatedly, looking for a bug in it, when the actual bug was in the clients.

Exponential backoff is the first fix. If a client waits 1s, then 2s, then 4s, then 8s, the number of requests it sends during a 60-second outage collapses from dozens to a handful, and the gaps grow wide enough that the dependency gets real idle time to work through its backlog.

But backoff alone leaves a second problem intact. If all your clients failed at the same moment — which is exactly what happens, because they all depend on the same thing — then they all compute the same delay schedule and retry in lockstep. You’ve replaced a continuous flood with a series of synchronized spikes. The dependency comes up, gets hit by 5,000 simultaneous retries, falls over, and the next spike lands 8 seconds later. This is the thundering herd, and it’s why jitter isn’t a refinement. It’s half the mechanism.

Backoff caps the rate; jitter fixes the shape

Jitter means adding randomness to the computed delay so the retries of a client population spread across the interval instead of stacking at its edge. There are three variants worth knowing, and the difference between them matters less than people argue but more than nothing.

Full jittersleep = random(0, min(cap, base * 2^attempt)) — spreads retries uniformly across the whole window. It’s the default I reach for. It produces the flattest load curve, at the cost of sometimes retrying much sooner than the nominal schedule.

Equal jittersleep = half + random(0, half) where half = min(cap, base * 2^attempt) / 2 — guarantees a minimum wait while still spreading the second half. Use it when retrying too fast has a real cost and you want a floor under the delay.

Decorrelated jittersleep = min(cap, random(base, previous_sleep * 3)) — walks the delay up from the previous actual sleep rather than from the attempt number. It tends to recover faster than full jitter after transient blips while still spreading load. AWS’s Architecture Blog popularized this comparison, and it’s worth reading the original rather than the many posts that restate it.

Pick one and be consistent. The thing that actually differentiates a working retry layer from a broken one isn’t which jitter variant you chose — it’s everything in the next three sections.

The implementation

Here’s the shape I ship. It is deliberately boring, and every parameter in it exists because I’ve seen its absence cause an incident.

import random
import time

class RetryableError(Exception):
    """Raised by callers for failures a retry could plausibly fix."""
    def __init__(self, message, retry_after=None):
        super().__init__(message)
        self.retry_after = retry_after  # seconds, if the server told us


def call_with_retry(
    operation,
    *,
    deadline_seconds=30.0,   # total budget for the whole operation
    max_attempts=6,          # backstop, not the primary limit
    base_delay=0.2,
    cap_delay=20.0,
    clock=time.monotonic,
    sleep=time.sleep,
):
    started = clock()
    last_error = None

    for attempt in range(max_attempts):
        try:
            return operation()
        except RetryableError as error:
            last_error = error
        # Anything not RetryableError propagates immediately: it is either a
        # deterministic failure, or one we cannot prove is safe to repeat.

        elapsed = clock() - started
        remaining = deadline_seconds - elapsed
        if remaining <= 0 or attempt == max_attempts - 1:
            break

        if last_error.retry_after is not None:
            # Server-driven backoff wins over anything we computed locally,
            # but still gets jitter so the herd doesn't re-form on its clock.
            delay = last_error.retry_after * random.uniform(0.8, 1.2)
        else:
            ceiling = min(cap_delay, base_delay * (2 ** attempt))
            delay = random.uniform(0, ceiling)   # full jitter

        delay = min(delay, remaining)
        if delay <= 0:
            break
        sleep(delay)

    raise last_error

Three details that are easy to skip and expensive to skip:

clock and sleep are injected so this is testable without real time passing. A retry layer you can’t unit-test is a retry layer whose behaviour under a two-minute outage is a guess.

The deadline is checked and the delay is clamped to what’s left of it. Without the clamp, a client with a 30-second budget can sleep 20 seconds on attempt six and then make a request it has no time to wait for — burning a request on the struggling dependency purely to throw the response away.

Only RetryableError is retried. Everything else propagates. That’s the classification decision, and it deserves its own section.

Classify before you retry

A retry is a bet that the failure was about the world, not about the request. Most bad retry behaviour I see is a client that never made that distinction and retries everything.

Retry: connection failures, 429s, 503s, explicit “try again” errors from the dependency, lock contention, and — carefully — read timeouts. These are conditions where the same request, sent later, has a genuinely different chance of succeeding.

Don’t retry: 400s, 401s, 403s, 404s, validation errors, serialization failures, and anything else that will fail identically on attempt fifty. Retrying a deterministic failure is pure waste: you burn client capacity, you add load to a dependency that is already telling you no, and you delay the error the caller needs to see. This is the same taxonomy that decides what belongs in a dead letter queue — transient failures get the retry budget, deterministic ones get routed out of the retry path immediately.

The dangerous class is timeouts. When a request times out, you don’t know whether the work happened. The server may have processed it completely and failed to get the response back to you. Retrying a timed-out read is fine. Retrying a timed-out write is a correctness decision, not a reliability one — and the only way to make it safe is for the operation to be idempotent, so that a duplicate is a no-op rather than a second charge. That is the precondition for this entire pattern, and I’ve written the long version of it in how to design an idempotent job queue. If your writes aren’t idempotent, adding retries doesn’t make your system more reliable. It makes it wrong more often.

Budget in time, not attempts

max_attempts=5 sounds like a limit. It isn’t a useful one, because it doesn’t tell you how long a caller will wait. With a 20-second cap and five attempts, the worst case is well over a minute — which is fine for a background job and catastrophic for a request with a user attached to it.

Set a deadline for the operation, derive the retries from what fits inside it, and propagate that deadline down the call chain. If the API gateway gives a request 5 seconds and it has already spent 3, the service it calls should be told it has 2 — not allowed to start its own fresh 5-second retry schedule. Attempt counts should exist only as a backstop against pathological loops.

This one change eliminates an entire class of incident where a request that the client abandoned long ago is still being retried by three services downstream, each burning capacity on work whose result nobody will ever read.

The retry budget nobody sets

Here’s the limit that’s missing from almost every retry implementation I review, and the one I’d add first if I could only add one.

Per-request backoff bounds what a single operation does. It says nothing about what your fleet does. During a total dependency outage, every request fails, every request retries the full schedule, and your aggregate outbound volume goes up by a multiple even with perfect backoff and jitter. The client fleet becomes a load generator aimed at a service that is trying to come back.

The fix is a retry budget: cap retries as a fraction of total requests, per client, over a rolling window. Something like 10% — meaning that in steady state, where failures are rare, every retry is affordable, but during a broad outage retries are cut off almost entirely and the failure is surfaced to the caller instead of amplified.

import time

class RetryBudget:
    """Token bucket: retries may not exceed `ratio` of total requests."""

    def __init__(self, ratio=0.1, ttl_seconds=10.0, min_tokens=10.0,
                 clock=time.monotonic):
        self.ratio = ratio
        self.ttl = ttl_seconds
        self.min_tokens = min_tokens   # floor, so low-traffic clients can retry
        self.clock = clock
        self.tokens = min_tokens
        self.updated = clock()

    def _decay(self):
        # Tokens earned in the window expire, so the budget reflects
        # recent traffic rather than all traffic since process start.
        now = self.clock()
        elapsed = now - self.updated
        self.updated = now
        if elapsed >= self.ttl:
            self.tokens = self.min_tokens
        else:
            decayed = (self.tokens - self.min_tokens) * (1 - elapsed / self.ttl)
            self.tokens = self.min_tokens + max(0.0, decayed)

    def on_request(self):
        self._decay()
        self.tokens += self.ratio

    def try_retry(self):
        self._decay()
        if self.tokens >= 1.0:
            self.tokens -= 1.0
            return True
        return False   # budget exhausted: fail fast, don't amplify

Wire on_request() into every attempt and gate every retry on try_retry(). The behavioural change is exactly what you want: an isolated failure retries freely, and a systemic failure stops retrying almost immediately. The min_tokens floor matters — without it, a service that handles two requests a minute can never retry at all.

Retry amplification: retry at one layer

Retries multiply through a call stack. If your gateway retries three times, and the service it calls retries three times, and that service’s database client retries three times, one user request can become twenty-seven requests to the bottom layer. Each layer looks reasonable in isolation. The product is not.

My rule: retry at one layer per failure domain, and make every other layer fail fast. Usually that’s the layer closest to the failure — the one that has the most information about whether the error is transient. Everything above it propagates. When you do need retries at two levels, the outer level’s budget has to account for the inner level’s multiplication, which in practice means the outer level gets one retry, not five.

The same reasoning applies to worker pools pulling from a queue: if the queue already redelivers failed messages with its own backoff, an in-worker retry loop layered on top means each message is being retried at two levels simultaneously. That’s the kind of interaction that only shows up under load, which is why I treat retry topology as an architectural property of the worker fleet rather than a per-client implementation detail.

Let the server drive when it can

A dependency that returns 429 Too Many Requests with a Retry-After header is telling you the answer. Honour it. Server-side backoff signals are strictly better information than anything a client can infer, because the server knows its own recovery state and the client is guessing from a single failed request.

Two caveats. First, still jitter around the server’s value — if a thousand clients all receive Retry-After: 5, obeying it exactly reproduces the thundering herd on the server’s own clock. Multiply it by a random factor near 1. Second, clamp it: a Retry-After of 3600 shouldn’t put your worker to sleep for an hour, it should fail the operation and let the job be rescheduled by the queue.

Backoff also pairs naturally with a circuit breaker, and the two do different jobs. Backoff paces an individual caller’s attempts. A breaker stops the calls altogether once failures cross a threshold, so that a dependency everyone knows is down stops receiving traffic at all. Backoff without a breaker still sends a trickle from every client forever; a breaker without backoff slams the dependency the moment it half-opens. Systems that survive real pressure use both, which is part of the broader argument about designing for disruption.

What to measure

Retry logic is invisible until it’s pathological, so instrument it deliberately:

  • Retry ratio — retries as a fraction of total requests, per dependency. This is your early warning; it rises well before error rates do.
  • Attempts-to-success distribution — if most successes take three attempts, your first attempt is failing routinely and you’re papering over a real problem.
  • Budget exhaustion events — every time the retry budget refuses a retry, something systemic is happening.
  • Time spent sleeping in backoff — surfaces the case where callers are waiting far longer than anyone intended.

Alert on the retry ratio crossing a threshold, not on individual retries. A retry is normal; a change in how often you’re retrying is the signal. That distinction — instrument everything, alert on the few things a human should act on — is the whole argument in monitoring vs alerting.

When you don’t need this

If you have exactly one client and a dependency that rarely fails, a fixed short delay and two attempts is genuinely fine — the herd problem needs a herd, and jitter buys you nothing against a population of one.

If the caller is a user waiting on a screen, failing fast and letting them retry deliberately is often better than silently burning their patience in a backoff loop. Put the retries behind the interaction, not inside it.

If your work already flows through a queue that redelivers with its own backoff, don’t add a second retry loop inside the worker; you’ll get multiplication, not resilience. Configure the broker’s schedule instead.

And if your writes aren’t idempotent, retries are the wrong project. Fix that first — otherwise every improvement to your retry logic increases the rate at which you double-apply side effects.

FAQ

What’s a good base delay and cap? Base it on your dependency’s actual recovery behaviour, not a convention. For a fast internal service, a base of 100–200ms with a cap of 10–20s is a reasonable starting point; for a rate-limited third-party API, the cap should be closer to its published window. The cap matters more than the base — it’s what stops exponential growth from producing absurd delays on later attempts.

Full jitter or decorrelated jitter? Full jitter is the safer default and easier to reason about. Decorrelated jitter recovers somewhat faster from brief blips because the delay walks up from the previous actual sleep instead of resetting to the schedule. If you’re choosing between them without a way to measure the difference on your own traffic, take full jitter and spend the effort on the retry budget instead.

Should I retry a timeout? Only if the operation is idempotent, or if it’s a read. A timeout means you don’t know whether the work happened, so retrying a non-idempotent write is a decision to sometimes apply it twice. Make the operation safe to repeat, then retries are free.

How many retries is too many? Any number that lets a systemic outage multiply your traffic. That’s why I bound by deadline and by fleet-wide budget rather than by attempt count — five attempts is fine when 0.1% of requests fail and catastrophic when 100% do.

Do I still need a circuit breaker if I have backoff and jitter? Yes, for prolonged outages. Backoff reduces each client’s rate but never to zero, so a large fleet still delivers steady load to a dead dependency. A breaker takes that to zero and lets it recover, then probes carefully before restoring traffic.

Where should the retry logic live? In one place per failure domain — a shared client wrapper, a service mesh policy, or the queue’s redelivery config — never scattered across call sites. Scattered retry logic is how amplification gets built by accident, one reasonable-looking three-attempt loop at a time.

The rest of the reliability layer these patterns sit inside — idempotency, dead-letter handling, backpressure, and observability that stays honest under load — is collected in my guides on reliable systems, queues and observability, and putting it into a running system is what I do in monitoring and operations work.

Professional headshot of Ben Moataz
Written by
Ben Moataz

Systems Architect, Consultant, and Product Builder

This article is grounded in hands-on work across Collection and orchestration and Monitoring and operations, including systems such as TraxinteL, WingAgent, and Armada.

I write from hands-on work across product systems, evidence pipelines, ranking layers, monitoring surfaces, and automation runtimes that have to stay reliable under operational pressure.

  • Years spent building product systems, automation infrastructure, and operator-facing platforms.
  • Project records and case studies tied directly to the same capability lanes discussed in the writing.
  • A public archive designed to connect essays back to real systems, delivery constraints, and consulting work.
Relevant work

Expertise and case studies tied to this article.

Related reading

More writing on adjacent systems problems.

Next article

Reranking in RAG: How a Cross-Encoder Fixes Retrieval Quality

A cross-encoder reranker re-scores your top candidates by reading query and document together. Here's how I add one, size it, and prove it worked.

Work with me

Building or fixing a system like this?

This is exactly the kind of work I get brought in for. Teams unsure whether a system, architecture, or workflow will hold up under real load and scrutiny.

System Audit Start here · fixed scope
  • A focused review of the system, architecture, or codebase in question.
  • A clear map of the risks, bottlenecks, and failure modes that matter.
  • A prioritized roadmap — what to fix first, and what to leave alone.
Subscribe

Get new essays by email

Field notes on intelligence systems, evidence engineering, and automation that survives reality. No noise.

Subscribe via RSS → Email capture isn't wired up yet — the RSS feed is live now.