1. Why Retries Are Dangerous
“If a request fails transiently, just retry it” sounds intuitively correct. Networks blip, servers get momentarily overloaded, and a deployment can cause a handful of requests to fail for a second or two. Having a client automatically retry through this kind of transient failure looks like desirable behavior.
The problem is that behavior that is rational for a single client becomes catastrophic the moment a large number of clients do the same thing at the same time. The typical scenario goes like this:
- A service becomes temporarily overloaded, and a burst of requests come back as errors.
- The clients that received errors (tens, hundreds, or thousands of them) all retry at roughly the same moment.
- The flood of retries pushes the load on the service even higher, and the service becomes even less able to keep up.
- The requests that failed again get retried again, and the load snowballs.
This phenomenon is generally called a retry storm, a special case of the thundering herd problem, where a large number of clients demand the same piece of work at the same moment1. The cruel irony is that clients trying to help the service recover end up, through their own well-intentioned retries, knocking it down further — a kind of “friendly-fire DDoS.” It shares its underlying structure with the long-known networking phenomenon of congestive collapse, in which throughput actually drops as load increases, and the system cannot recover on its own.
Marc Brooker, a Principal Engineer at AWS, describes “timeouts, retries, and backoff” as the three pillars Amazon relies on to build reliable remote calls2. Put differently: a naive retry with no timeout and no backoff is, by itself, a dangerous tool. This article focuses on backoff, specifically exponential backoff and jitter, working through the definitions from AWS’s own primary source, and then verifying the claims with a hand-rolled discrete-event simulation.
2. The Problem With Fixed-Interval Retries: Synchronized Waves
The simplest retry scheme is fixed-interval retry: “if it fails, try again after a fixed delay.” It’s trivial to implement, but it causes serious problems in a scenario like this:
- At some moment (e.g., a brief outage or a deploy blip), a large number of client calls fail simultaneously.
- If every client retries after the same fixed delay \(d\) , then \(d\) seconds later almost everyone sends a request again, at the same moment.
- If the server’s processing capacity is finite, only some of this synchronized retry wave succeeds; the rest fail again.
- The clients that failed again will, again, all retry after the same \(d\) seconds.
In other words, the synchronization created at the initial failure does not resolve on its own under fixed-interval retry. The group of clients keeps colliding with the capacity ceiling repeatedly, behaving as a single shrinking cohort. The concept is illustrated below.

This diagram is a simplified illustration of the concept; the simulation in Section 5 measures quantitatively how this “wave” actually behaves in a concrete model.
3. Exponential Backoff: The Synchronization Problem That Still Remains
The standard fix for fixed-interval retry’s shortcomings is exponential backoff. The wait time \(t_n\)
on the \(n\)
-th retry starts from a base value base and doubles each time, capped at an upper bound cap:
Rather than a fixed interval, stretching the wait time on every retry gives the intuitively desirable property that “a client backs off more the longer failures continue.” The AWS Architecture Blog presents this as the first improvement over naive fixed-interval retry3.
There’s a subtlety that’s easy to miss, though. Exponential backoff only stretches the interval — it does not break the synchronization. If \(N\) clients fail at exactly the same instant, and there is no jitter, every one of them computes the exact same \(t_n\) , so their \(n\) -th retry still happens at exactly the same instant. What changes is only that the gap between waves grows exponentially; the size of a single wave — how many requests arrive at the server at once — is essentially unchanged by exponential backoff alone. Whether this intuition holds up is exactly what Section 5’s simulation measures.
4. The Three Jitter Strategies: Full, Equal, and Decorrelated
Jitter — adding randomness to the wait time — is what’s needed to break the synchronization. This idea, along with three concrete strategies, was laid out by Marc Brooker in the 2015 AWS Architecture Blog post “Exponential Backoff and Jitter”3. The pseudocode for the three strategies defined in that post is as follows (sleep is the actual wait time, attempt is the retry count):
Full Jitter
\[ \text{sleep} = \text{random}\bigl(0,\ \min(\text{cap}, \text{base}\cdot 2^{\text{attempt}})\bigr) \]Treat the exponential-backoff value \(\min(\text{cap}, \text{base}\cdot2^{\text{attempt}})\) as an upper bound, and draw the wait time uniformly from 0 up to that bound. It is the simplest strategy and the one with the widest randomness.
Equal Jitter
\[ d = \min(\text{cap}, \text{base}\cdot 2^{\text{attempt}}), \qquad \text{sleep} = \frac{d}{2} + \text{random}\Bigl(0,\ \frac{d}{2}\Bigr) \]This guarantees a wait time of at least \(d/2\) , with randomness applied only to the remaining half. Unlike Full Jitter, a client can never get “lucky” and retry almost immediately, but the amount of randomness is only half as wide.
Decorrelated Jitter
\[ \text{sleep} = \min\bigl(\text{cap},\ \text{random}(\text{base},\ \text{sleep}_{\text{prev}} \cdot 3)\bigr) \]This carries forward the previous wait time \(\text{sleep}_{\text{prev}}\) and draws the next one randomly from up to three times that value (on the first attempt, \(\text{sleep}_{\text{prev}} = \text{base}\) ). Unlike the other two strategies, it never explicitly computes \(2^{\text{attempt}}\) , so each client’s sequence of wait times follows its own distinct random trajectory.
The original AWS Architecture Blog post ran a simulation with 100 contending clients and reported that “backoff without jitter takes so much more time that it can’t fit on the same graph,” and that “Full Jitter and Decorrelated Jitter perform about equally well, and both clearly beat Equal Jitter” in terms of total call volume3. Rather than take this conclusion at face value, this article builds a similar simulation from scratch and checks it directly.
5. Comparing the Strategies With Simulation
5.1 Model Setup
I built a discrete-event simulation comparing five strategies — fixed interval, exponential backoff without jitter, Full Jitter, Equal Jitter, and Decorrelated Jitter — under the following model.
- Number of clients \(N\) : \(N\) clients all send their first request at tick 0 and all of them fail (modeling the initial condition of a brief outage).
- Server capacity \(k\) : the server can accept at most \(k\) requests per tick. If the number of arrivals in a given tick is \(\le k\) , all succeed; if it exceeds \(k\) , exactly \(k\) of them (chosen at random) succeed and the rest fail, each scheduling its next retry according to its assigned strategy.
- Parameters:
base=1,cap=32, fixed intervalfixed_delay=2, server capacityk=10. - Metrics: the total time (in ticks) until every client has succeeded, and the total number of calls made to the server over the whole run (successes and failures combined).
- Trials: for each \(N \in \{50, 100, 200\}\) and each strategy, I ran 30 trials with different random seeds and report the mean and standard deviation.
The implementation (Python, using numpy) is as follows.
import numpy as np
def simulate(N, k, policy, base=1.0, cap=32.0, fixed_delay=2.0, seed=0, max_ticks=4000):
rng = np.random.default_rng(seed)
n = np.zeros(N, dtype=int) # each client's retry count
prev_sleep = np.full(N, base, dtype=float) # previous sleep, for decorrelated jitter
scheduled_tick = np.zeros(N, dtype=int) # tick of the next request (all start at 0)
succeeded = np.zeros(N, dtype=bool)
total_calls = 0
arrivals_per_tick = []
last_active_tick = 0
for tick in range(max_ticks):
idx = np.where((~succeeded) & (scheduled_tick == tick))[0]
m = len(idx)
arrivals_per_tick.append(m)
if m == 0:
if succeeded.all():
break
continue
total_calls += m
last_active_tick = tick
if m <= k:
succeeded[idx] = True
else:
chosen = rng.choice(idx, size=k, replace=False)
succeeded[chosen] = True
failed = np.setdiff1d(idx, chosen)
for i in failed:
ni = n[i] + 1
n[i] = ni
if policy == "fixed":
delay = fixed_delay
elif policy == "expo":
delay = min(cap, base * (2 ** ni))
elif policy == "full_jitter":
delay = rng.uniform(0, min(cap, base * (2 ** ni)))
elif policy == "equal_jitter":
d = min(cap, base * (2 ** ni))
delay = d / 2 + rng.uniform(0, d / 2)
elif policy == "decorr_jitter":
delay = min(cap, rng.uniform(base, prev_sleep[i] * 3))
prev_sleep[i] = delay
new_tick = tick + max(1, int(round(delay)))
scheduled_tick[i] = new_tick
if succeeded.all():
break
total_time = last_active_tick + 1
return total_calls, total_time, arrivals_per_tick
Server capacity is treated as a one-tick bucket: if the number of arrivals in that tick exceeds capacity, the excess fails; each client keeps track of its own retry count n (and, for Decorrelated Jitter, its own previous wait time) to compute its next wait time. For every \((N, \text{policy})\)
combination, I ran 30 trials with different random seeds and aggregated the mean and standard deviation.
5.2 Measured Results: Total Calls and Total Time
The mean values (± standard deviation) over 30 trials are as follows.
| \(N\) | Strategy | Total time (ticks) | Total calls | Reduction vs. fixed |
|---|---|---|---|---|
| 50 | Fixed interval | 9.00 ± 0.00 | 150.0 ± 0.0 | — |
| 50 | Exponential backoff (no jitter) | 31.00 ± 0.00 | 150.0 ± 0.0 | 0.0% |
| 50 | Full Jitter | 8.63 ± 1.50 | 117.8 ± 2.1 | 21.5% |
| 50 | Equal Jitter | 8.33 ± 2.35 | 110.4 ± 0.7 | 26.4% |
| 50 | Decorrelated Jitter | 10.00 ± 1.44 | 103.2 ± 2.0 | 31.2% |
| 100 | Fixed interval | 19.00 ± 0.00 | 550.0 ± 0.0 | — |
| 100 | Exponential backoff (no jitter) | 191.00 ± 0.00 | 550.0 ± 0.0 | 0.0% |
| 100 | Full Jitter | 20.63 ± 5.26 | 316.8 ± 3.9 | 42.4% |
| 100 | Equal Jitter | 19.67 ± 5.71 | 294.7 ± 3.4 | 46.4% |
| 100 | Decorrelated Jitter | 16.93 ± 2.55 | 272.3 ± 5.3 | 50.5% |
| 200 | Fixed interval | 39.00 ± 0.00 | 2100.0 ± 0.0 | — |
| 200 | Exponential backoff (no jitter) | 511.00 ± 0.00 | 2100.0 ± 0.0 | 0.0% |
| 200 | Full Jitter | 40.67 ± 3.59 | 814.0 ± 6.3 | 61.2% |
| 200 | Equal Jitter | 48.17 ± 9.73 | 763.2 ± 4.5 | 63.7% |
| 200 | Decorrelated Jitter | 36.30 ± 4.14 | 751.7 ± 9.3 | 64.2% |

I also plotted, for one trial with \(N=100\) , the time series of the number of requests arriving at the server on each tick.

5.3 Discussion
The measured results broadly matched the qualitative expectations, while also surfacing a few facts that were sharper than expected.
(1) Exponential backoff without jitter produces an exactly identical total call count to fixed-interval retry. For all of \(N=50,100,200\) , “exponential backoff (no jitter)” matched the fixed-interval total call count exactly (150.0 / 550.0 / 2100.0), with zero difference. This is not a coincidence. If all clients fail at exactly the same instant and there is no jitter, no randomness is drawn, so the surviving cohort of clients remains, forever, “one single group that all share the same retry count \(n\) .” How the cohort shrinks — \(N \to N-k \to N-2k \to \cdots\) — is identical regardless of whether the wait time grows fixed or exponentially. In other words, exponential backoff by itself does nothing to break synchronization — the intuition from Section 3 is now backed by a quantitative measurement.
(2) Despite that, exponential backoff without jitter’s total completion time is dramatically worse than fixed interval’s. At \(N=200\) , fixed interval finishes in 39 ticks, while exponential backoff without jitter takes 511 ticks — over 13 times slower. The fact that total call counts are identical while only completion time degrades follows directly from the fact that exponential backoff, without jitter, keeps the size of each colliding wave unchanged while only stretching out the interval between collisions. This shows a somewhat counter-intuitive but important conclusion: exponential backoff without jitter, applied carelessly, mostly just delays recovery, without meaningfully protecting the server.
(3) All three jittered strategies reduced total calls by 21–64% relative to fixed interval and no-jitter exponential backoff. The reduction grew larger as \(N\) increased (for Full Jitter: 21.5% → 42.4% → 61.2%). This means that the larger the ratio \(N/k\) — the initial overload multiple relative to per-tick capacity — the greater the benefit from jitter’s spreading effect. That is precisely the regime where retry-storm risk is highest, which is a desirable property in practice.
(4) The relative ranking among the three jitter strategies did not show the same clean consistency as the AWS blog post’s conclusion. AWS’s original post reports Full Jitter and Decorrelated Jitter performing about equally well and clearly ahead of Equal Jitter. In this simulation, Decorrelated Jitter had the lowest total call count in 2 of the 3 settings (\(N=100,200\) ), and was the fastest or tied-fastest to complete in all three settings. Full Jitter, if anything, had a slightly higher total call count than the other two jittered strategies. That said, the differences are all on the order of tens of calls or a few ticks, and given the standard deviations involved (e.g., Equal Jitter’s completion time at \(N=200\) was \(48.17 \pm 9.73\) ), the broader conclusion — “all three strategies perform similarly well, and all three dramatically outperform fixed interval and no-jitter exponential backoff” — is more robust than any specific ranking among them. The AWS post itself describes Full Jitter and Decorrelated Jitter as “about equal,” and these results fall well within that range. Equal Jitter, because it guarantees a wait-time floor, also showed the largest variance (standard deviation) in completion time among the three strategies, an effect that appeared consistently across settings.
Taken together, whether jitter is used at all is the dominant factor governing total call volume, while the specific choice among Full/Equal/Decorrelated Jitter is a secondary consideration — a practical conclusion that this simulation backs up quantitatively.
6. Practical Design Guidelines
Based on the simulation results, here are guidelines for actually implementing retries.
- Cap the number of retries. Unbounded retries keep consuming client-side resources (connections, threads) while a dependency is fully down. In addition to capping the wait time with
cap, cap the retry count itself (e.g., 3–5 attempts) and propagate the failure to the caller once that limit is exceeded. - Design timeouts before retries. Retries only make sense once it’s clear when a single call counts as having failed. The AWS Builders’ Library treats setting a timeout on every remote call as the foundation that comes before retries2. A timeout that’s too long lets connections and threads exhaust before enough retries even accumulate; one that’s too short causes requests that were actually succeeding to be treated as failures and retried anyway.
- Never forget that idempotency is a prerequisite. Retrying assumes it is safe to resend the same request. Retrying an API with side effects — like creating a payment — as-is can cause duplicate processing. Either issue an idempotency key so the server can detect duplicates, or decide that a non-idempotent operation simply should not be retried.
- Combine with a circuit breaker. Backoff with jitter controls when to retry; it doesn’t decide whether retrying makes sense at all. When a dependency is clearly down and staying down, a circuit breaker should stop retries altogether for a while, so as not to get in the way of the dependency’s own recovery. The local token-bucket mechanism the AWS Builders’ Library describes for capping retry volume2 is, in a broad sense, a form of circuit breaker too.
- Watch for retry amplification across client hierarchies. In a multi-hop call chain — edge → service A → service B → DB — if each layer retries independently, the effective number of calls hitting the deepest layer can be amplified by the product of each layer’s retry count (three layers each retrying 3 times can multiply up to \(3^3=27\times\) ). Retries should be confined to a single point in the call hierarchy (typically the outermost caller, or a single point in a client SDK), with inner layers propagating failures upward immediately instead of retrying independently.
7. Implementations Across Languages and Libraries
Before rolling your own backoff-and-jitter logic, most languages already have well-established libraries for this. It’s more practical to use an existing, battle-tested implementation than to reinvent the wheel.
Python: tenacity
tenacity lets you declare retry behavior with a @retry decorator, and provides wait_random_exponential as a wait strategy equivalent to Full Jitter.
from tenacity import retry, stop_after_attempt, wait_random_exponential
@retry(
wait=wait_random_exponential(multiplier=1, max=60), # equivalent to Full Jitter
stop=stop_after_attempt(5), # cap the retry count
)
def call_flaky_service():
...
wait_random_exponential(multiplier, max) sets the upper bound of the \(n\)
-th wait time to multiplier * 2^n, capped at max, and draws a uniform random value from 0 up to that bound — exactly the definition of Full Jitter. Combine it with stop_after_attempt to also apply the retry-count cap from guideline 1.
AWS SDK: retryMode (standard / adaptive)
AWS’s SDKs for each language (including boto3) let you switch retry behavior via a retryMode setting. There are three modes: legacy (the default, with limited retry coverage), standard (a standardized mode with exponential backoff and circuit-breaker-like behavior), and adaptive (an experimental mode that dynamically adjusts the client’s own send rate). Unless there’s a specific reason not to, explicitly specifying standard is recommended. The details of boto3’s authentication and retry configuration are covered in https://yuhi-sa.github.io/en/posts/20220220_aws_set/1/.
8. Conclusion
The idea that “retrying is safe” holds up from the perspective of a single client, but breaks down the moment a large number of clients fail at the same time. This simulation confirmed the following points quantitatively:
- Neither fixed-interval retry nor exponential backoff without jitter can, on their own, resolve the synchronization of a cohort of clients that has already synchronized (their total call counts matched exactly).
- Exponential backoff without jitter, because it stretches out the wait time without breaking the synchronization, actually makes total completion time dramatically worse (over 13x in this experiment).
- Adding jitter (Full/Equal/Decorrelated) reduced total call counts by 21–64%, with the reduction growing larger under heavier contention (larger \(N/k\) ).
- The differences among the three jitter strategies are small; whether jitter is used at all matters far more than which specific jitter strategy is chosen.
Exponential backoff and jitter can be written down in a handful of lines of math, but understanding why they work requires breaking the mechanism apart. By actually building and running the simulation myself, rather than taking AWS’s primary-source conclusions at face value, I was able to verify them for myself.
FAQ
Q1. How many times should a request be retried? A. There’s no single right answer, but capping retries at around 3–5 attempts, with the caller receiving an error beyond that, is a common starting point. Rather than fixating on the count itself, it’s more robust in practice to first decide the total deadline the whole retry sequence is allowed to take, and work backward from there to the count and the wait-time cap.
Q2. Which jitter strategy should I choose — Full, Equal, or Decorrelated?
A. This article’s simulation found only small differences among the three; all of them are overwhelmingly better than no jitter at all. Given how simple and battle-tested it is, starting with a library’s built-in Full-Jitter-equivalent implementation, such as tenacity’s wait_random_exponential, is a safe default.
Q3. Which should be designed first — timeouts or retries? A. Timeouts should come first. Without a defined timeout, there’s no well-defined moment at which a retry should even begin, so retry design has nothing to stand on.
Q4. Is it okay to retry a non-idempotent API? A. As a rule, no. Operations with side effects, such as creating a payment, risk duplicate processing if retried as-is. It’s only safe to retry when the server supports deduplication via an idempotency key, and that key is actually used.
Related Articles
- Modeling Database Connection Pool Sizing with Queueing Theory (M/M/c) - Covers a related theme — load concentrating on a capacity-limited server — from the perspective of queueing theory, sharing common ground with this article’s server-capacity model.
- Getting Started with boto3 (Python × AWS) - Explains how to configure the AWS SDK’s retryMode (standard / adaptive).
For a broader reading list, see 10 Must-Read Technical Books for Engineers .
References
Thundering herd problem. Wikipedia. https://en.wikipedia.org/wiki/Thundering_herd_problem ↩︎
Brooker, M. (2019). Timeouts, retries, and backoff with jitter. Amazon Builders’ Library. https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/ ↩︎ ↩︎ ↩︎
Brooker, M. (2015). Exponential Backoff And Jitter. AWS Architecture Blog. https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ ↩︎ ↩︎ ↩︎