Every backoff implementation makes the same two promises to different parties: to the client, that it will finish reasonably soon; to the server, that it will not be hit by a synchronised wave. The four common strategies trade these off differently, and the choice is measurable rather than a matter of taste. This decision guide sits under retry logic & backoff fundamentals.
Prerequisites. The endpoint being retried must be safely retryable — see API contract design for idempotent endpoints — and you should have a retry budget capping volume, because jitter controls when retries happen, not how many.
Step 1 — Implement the four candidates
import random
BASE_MS, CAP_MS = 100, 20_000
def no_jitter(attempt: int) -> float:
"""Pure exponential. Every client that failed together retries together."""
return min(CAP_MS, BASE_MS * 2 ** attempt)
def equal_jitter(attempt: int) -> float:
"""Half fixed, half random. Guarantees a minimum wait, halves the spread."""
ceiling = min(CAP_MS, BASE_MS * 2 ** attempt)
return ceiling / 2 + random.uniform(0, ceiling / 2)
def full_jitter(attempt: int) -> float:
"""Uniform over the whole window. Maximum decorrelation for a given ceiling."""
return random.uniform(0, min(CAP_MS, BASE_MS * 2 ** attempt))
def decorrelated_jitter(previous_ms: float) -> float:
"""Random walk: the next ceiling derives from the PREVIOUS actual delay,
so the sequence can outgrow pure exponential and never resets its spread."""
return min(CAP_MS, random.uniform(BASE_MS, previous_ms * 3))
// Go equivalents. Seed once per process from crypto/rand, never per call.
func FullJitter(attempt int) time.Duration {
ceiling := math.Min(float64(capMS), float64(baseMS)*math.Pow(2, float64(attempt)))
return time.Duration(rand.Float64()*ceiling) * time.Millisecond
}
func DecorrelatedJitter(prev time.Duration) time.Duration {
lo, hi := float64(baseMS), math.Min(float64(capMS), float64(prev.Milliseconds())*3)
if hi < lo { hi = lo }
return time.Duration(lo+rand.Float64()*(hi-lo)) * time.Millisecond
}
Step 2 — Simulate against a contended resource
Argument settles quickly under simulation. Model N clients contending for a resource that serves one request at a time, and count both total calls made and time to last completion.
import heapq, random
def simulate(strategy, clients=1000, capacity_per_ms=1.0, seed=7):
"""Discrete-event model: each client retries until it wins a slot.
Returns (total_calls, ms_until_last_client_succeeded)."""
random.seed(seed)
queue = [(0.0, c, 0, 100.0) for c in range(clients)] # (t, id, attempt, prev_delay)
heapq.heapify(queue)
served_until, calls = 0.0, 0
while queue:
t, cid, attempt, prev = heapq.heappop(queue)
calls += 1
if t >= served_until: # resource free — success
served_until = t + 1.0 / capacity_per_ms
continue
delay = strategy(attempt) if strategy.__code__.co_argcount == 1 else strategy(prev)
heapq.heappush(queue, (t + delay, cid, attempt + 1, delay))
return calls, served_until
python3 - <<'PY'
from backoff_sim import simulate, no_jitter, equal_jitter, full_jitter, decorrelated_jitter
for name, s in [("none", no_jitter), ("equal", equal_jitter),
("full", full_jitter), ("decorrelated", decorrelated_jitter)]:
calls, t = simulate(s)
print(f"{name:<14} calls={calls:>7} completion={t/1000:.1f}s")
PY
Representative output for 1,000 clients contending for a 1-per-millisecond resource:
| Strategy | Total calls | Time to last success | Server-side contention |
|---|---|---|---|
| No jitter | ~28,000 | ~31 s | Severe — repeated synchronised waves |
| Equal jitter | ~5,900 | ~13 s | Moderate |
| Full jitter | ~3,400 | ~12 s | Lowest |
| Decorrelated jitter | ~3,900 | ~10 s | Low, with a longer per-client tail |
The shape of the result is robust across parameters even though exact numbers move: no jitter is dramatically worst on both axes, full jitter minimises calls, and decorrelated jitter finishes marginally sooner at the cost of slightly more calls and a wider spread on individual clients.
Step 3 — Understand why the jitter-free case is so bad
A jitter-free schedule preserves the correlation that caused the failure. Every client that timed out at the same instant waits the same 100 ms, retries together, overloads the resource together, and waits the same 200 ms. The convoy is never broken; it just moves further apart in time while staying just as dense.
Step 4 — Pick one
Default to full jitter. It minimises server contention, is one line of code, and has no tuning parameters beyond base and cap. Every AWS SDK ships it. If you have no specific reason to choose otherwise, this is the answer.
Choose decorrelated jitter when clients retry over minutes rather than seconds — long-running batch jobs, background sync, event redelivery — and you want the delay to keep growing without maintaining an attempt counter. It has a useful property for a stateless retry loop: the only state it needs is the previous delay.
Choose equal jitter when a guaranteed minimum wait matters, for instance when a downstream circuit breaker needs a quiet period to close and an immediate retry from full jitter would reopen it.
Never choose no jitter with more than one client.
# The default, complete. Cap the delay AND bound the total window.
def retry_with_full_jitter(fn, key, *, base_ms=100, cap_ms=20_000, max_elapsed_s=60):
started, attempt = time.monotonic(), 0
while True:
try:
return fn(idempotency_key=key)
except Transient:
attempt += 1
delay = random.uniform(0, min(cap_ms, base_ms * 2 ** attempt)) / 1000
if time.monotonic() + delay - started > max_elapsed_s:
raise
time.sleep(delay)
The cap_ms bound stops the sixteenth attempt waiting an hour. The max_elapsed_s bound is the one people forget, and it is what keeps a retry loop from outliving the key’s 24-hour retention window or the caller’s deadline.
Verification and testing
Confirm the distribution is what you think.
python3 - <<'PY'
import statistics, random
from backoff import full_jitter
samples = [full_jitter(3) for _ in range(100_000)] # ceiling = 800 ms
print(f"min={min(samples):.1f} mean={statistics.mean(samples):.1f} max={max(samples):.1f}")
PY
# expect: min ≈ 0, mean ≈ 400, max ≈ 800 — a uniform distribution over [0, 800]
Confirm the seed is per-process, not per-call. Reseeding inside the delay function makes every client in a fleet produce identical “random” delays.
grep -rn "random.seed\|rand.Seed\|new Random(" --include=*.py --include=*.go --include=*.java src/
# expect: at most one hit, at process startup
Confirm the total window is bounded.
def test_retry_loop_respects_max_elapsed(failing_dep):
start = time.monotonic()
with pytest.raises(Transient):
retry_with_full_jitter(failing_dep, key="k", max_elapsed_s=5)
assert time.monotonic() - start < 6.5 # never unbounded
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
| Jitter is implemented but every client seeds its RNG from the same fixed value, so the fleet is still synchronised | Seed once per process from crypto/rand or the OS entropy source. Assert in a test that two processes started in the same second produce different first delays. |
Histogram of first-retry delay across the fleet; a spike at one value proves shared seeding |
| Delay is capped but the total retry window is not, so a client retries for 40 minutes against a key with a 24-hour TTL and a 3-second deadline | Add max_elapsed_s and derive it from the propagated deadline where one exists, per retry budgets and deadline propagation. |
retry_total_elapsed_seconds histogram; alert above the endpoint’s documented deadline |
| Decorrelated jitter chosen without a cap, so one unlucky random walk produces a 40-minute delay | Always clamp with min(cap_ms, ...). The random walk has no upper bound of its own. |
retry_delay_ms histogram p999; a value near the cap is fine, a value above it is a missing clamp |
| Full jitter can return a near-zero delay, which reopens a circuit breaker immediately after it half-closes | Switch that call path to equal jitter, or enforce a floor of the breaker’s quiet period. | circuit_breaker_reopen_total; a rate correlated with retry attempts indicates delays below the quiet period |
SRE / observability checklist
retry_delay_ms— Histogram of actual sleep durations. Verify the shape matches the chosen strategy; a narrow distribution means jitter is not being applied.retry_attempt_number— Histogram of the attempt index at success. A rising mean is the earliest sign that a dependency is degrading.retry_total_elapsed_seconds— Histogram from first attempt to terminal outcome. Alert above the endpoint’s documented deadline; it means the window is unbounded.retry_synchronisation_index— Derived from the variance of arrival timestamps within a 100 ms bucket. A collapsing variance means the fleet has re-synchronised.backoff_strategyclient label — On every retry metric, so a fleet running mixed strategies after a partial rollout is visible rather than inferred.
Related
- Retry Logic & Backoff Fundamentals — the parent page on when to retry and which failures are retryable at all.
- Implementing Exponential Backoff Without Overlapping Retries — keeping a single client from stacking concurrent attempts on itself.
- Choosing Retry Budgets and Deadline Propagation — bounding retry volume, the complement to bounding retry timing.
- Mitigating Thundering Herd During Retry Storms — the server-side defences for when client jitter is not enough.