System Design · lesson 5 of 11
Beginner
4Intermediate
4Advanced
3Also part of System Design
12- + Scaling Patterns: Real Sharding vs a Table That Only Looks Sharded from Databases
- + Killing the Primary and Measuring the Real Failover Time from MongoDB
- + ACID vs BASE: the Lost Update Neither Engine Prevents by Default from Databases
- + Distributed Correctness in Interviews from Interview Preparation
- + Events vs Requests: Why the Order Service Doesn't Wait for Email from Event-Driven Architecture
- + Scaling, Queues, and Backpressure from Cloud Interviews
- + Cache-Aside with Redis: Stale Reads, Races and Stampedes from Redis
- + Circuit Breaker: Stop Calling a Service That's Already Down from Microservices
- + API Gateway: Aggregating Calls With a Fallback from Microservices
- + Observability Synthesis: Logs, Metrics, Traces, and Alerts as One Signal from DevOps
- + Design a URL Shortener: Sequential Codes vs Real Hash Collisions from Low-Level Design
- + Availability, Regions, and the Arithmetic of Nines from Cloud Interviews
Also part of: Microservices
Scaling & Reliability: Retries With Backoff and Jitter
A naive retry hammers a struggling dependency at a fixed interval. Exponential backoff with jitter spaces retries out and spreads clients apart in time.
By SkillByExample · Updated
Retrying immediately at a fixed interval just re-hits a struggling dependency at full speed; backing off — and jittering that backoff — actually gives it room to recover.
A flaky call, backed off and retried until it succeeds
import random
import time
random.seed(42)
class TransientError(Exception):
pass
def call_with_retry(fn, max_attempts=4):
for attempt in range(1, max_attempts + 1):
try:
return fn()
except TransientError as e:
if attempt == max_attempts:
raise
backoff = min(2.0, 0.1 * (2 ** (attempt - 1)))
jitter = random.uniform(0, 0.05)
wait = backoff + jitter
print(f" attempt {attempt} failed ({e}), waiting {wait:.2f}s before retry")
time.sleep(wait)
raise RuntimeError("unreachable")
attempts = {"count": 0}
def flaky_call():
attempts["count"] += 1
if attempts["count"] < 3:
raise TransientError("gateway busy")
return "success"
start = time.perf_counter()
result = call_with_retry(flaky_call)
elapsed = time.perf_counter() - start
print(f"Result: {result} after {attempts['count']} attempts, {elapsed:.2f}s total")
$ python retry_with_backoff.py
attempt 1 failed (gateway busy), waiting 0.13s before retry
attempt 2 failed (gateway busy), waiting 0.20s before retry
Result: success after 3 attempts, 0.33s total
flaky_call fails on its first two calls and succeeds on the third — a real, played-out
sequence, not a described one. The waits themselves grow (0.1 * 2^0, then 0.1 * 2^1, each
plus a small random jitter), and the total elapsed time genuinely includes both real sleep
calls, which is why it lands a little above their sum rather than exactly on it.
Practice
1. Make the call always fail and confirm retries exhaust and the error propagates.
import random
import time
random.seed(42)
class TransientError(Exception):
pass
def call_with_retry(fn, max_attempts=3):
for attempt in range(1, max_attempts + 1):
try:
return fn()
except TransientError as e:
if attempt == max_attempts:
raise
backoff = min(2.0, 0.1 * (2 ** (attempt - 1)))
jitter = random.uniform(0, 0.05)
wait = backoff + jitter
print(f" attempt {attempt} failed ({e}), waiting {wait:.2f}s before retry")
time.sleep(wait)
raise RuntimeError("unreachable")
def always_fails():
raise TransientError("gateway permanently down")
try:
call_with_retry(always_fails, max_attempts=3)
except TransientError as e:
print(f"All retries exhausted, final error: {e}")
$ python exhaust_retries.py
attempt 1 failed (gateway permanently down), waiting 0.13s before retry
attempt 2 failed (gateway permanently down), waiting 0.20s before retry
All retries exhausted, final error: gateway permanently down
The third and final attempt’s failure isn’t followed by another wait — if attempt == max_attempts: raise re-raises immediately instead of sleeping before a retry that will never
happen.
2. Compare 5 clients' first-retry wait times with and without jitter.
import random
random.seed(7)
def wait_without_jitter(attempt):
return min(2.0, 0.1 * (2 ** (attempt - 1)))
def wait_with_jitter(attempt):
backoff = min(2.0, 0.1 * (2 ** (attempt - 1)))
return backoff + random.uniform(0, 0.05)
print("5 clients retrying attempt 1, no jitter:")
waits = [wait_without_jitter(1) for _ in range(5)]
print(f" wait times: {[round(w, 3) for w in waits]}")
print(f" all identical? {len(set(waits)) == 1}")
print()
print("5 clients retrying attempt 1, with jitter:")
waits = [wait_with_jitter(1) for _ in range(5)]
print(f" wait times: {[round(w, 3) for w in waits]}")
print(f" all identical? {len(set(waits)) == 1}")
$ python jitter_decorrelation.py
5 clients retrying attempt 1, no jitter:
wait times: [0.1, 0.1, 0.1, 0.1, 0.1]
all identical? True
5 clients retrying attempt 1, with jitter:
wait times: [0.116, 0.108, 0.133, 0.104, 0.127]
all identical? False
Without jitter, every client that failed at the same instant retries at the exact same instant too — a synchronized retry storm hitting the dependency right as it’s trying to recover. Jitter spreads that same set of clients across a small window instead.
3. Show the backoff plateau across 8 attempts with a 2-second cap.
def backoff_for(attempt, cap=2.0):
return min(cap, 0.1 * (2 ** (attempt - 1)))
for attempt in range(1, 9):
print(f"attempt {attempt}: backoff = {backoff_for(attempt):.2f}s")
$ python backoff_cap.py
attempt 1: backoff = 0.10s
attempt 2: backoff = 0.20s
attempt 3: backoff = 0.40s
attempt 4: backoff = 0.80s
attempt 5: backoff = 1.60s
attempt 6: backoff = 2.00s
attempt 7: backoff = 2.00s
attempt 8: backoff = 2.00s
The doubling stops mattering once 0.1 * 2^(attempt-1) exceeds the 2-second cap — from attempt
6 onward, min(cap, ...) always returns the cap itself, no matter how many more attempts follow.
4. Skip retries entirely for a non-retryable validation error.
class TransientError(Exception):
pass
class ValidationError(Exception):
pass
def call_with_retry(fn, max_attempts=3):
for attempt in range(1, max_attempts + 1):
try:
return fn()
except TransientError as e:
if attempt == max_attempts:
raise
print(f" attempt {attempt} failed (retryable: {e}), retrying")
except ValidationError as e:
print(f" attempt {attempt} failed (NOT retryable: {e}), giving up immediately")
raise
raise RuntimeError("unreachable")
def bad_request():
raise ValidationError("item_count must be positive")
try:
call_with_retry(bad_request)
except ValidationError:
print("Caller correctly sees a validation error, not silently retried 3 times")
$ python non_retryable_error.py
attempt 1 failed (NOT retryable: item_count must be positive), giving up immediately
Caller correctly sees a validation error, not silently retried 3 times
Catching ValidationError separately and re-raising immediately means a request that’s wrong
in a way retrying can never fix fails fast on the first attempt, instead of wasting 2 more
attempts and their backoff delays on a certain failure.