Retry with Backoff Pattern
Retry with backoff is the resilience pattern where, on a transient failure, the caller waits some duration and tries again — and the duration grows (typically exponentially) on each successive failure to avoid hammering a struggling downstream. Retries alone are not enough: a naïve “try, fail, immediately try again” loop turns a single transient failure into a tight loop that amplifies the load on the downstream just as it is least able to handle it. The technique that introduced exponential backoff in the networking context is the binary exponential backoff of Ethernet’s collision-resolution algorithm, described by Robert Metcalfe and David Boggs in their 1976 CACM paper Ethernet: Distributed Packet Switching for Local Computer Networks (per Metcalfe & Boggs 1976) and later standardized in IEEE 802.3 (1983); TCP’s retransmission backoff was popularized by Van Jacobson’s 1988 congestion-avoidance work. All of these established that the delay before retrying must grow with each attempt to give the shared resource room to recover. The modern restatement in distributed systems comes from Marc Brooker’s Exponential Backoff and Jitter (AWS Architecture Blog, 2015) and the Timeouts, Retries, and Backoff with Jitter AWS Builders’ Library article, which expanded the backoff discipline to include jitter — randomization of the delay — to prevent the most pernicious failure mode in synchronous-retry systems: many clients retrying at exactly the same moment, amplifying the load on the recovering downstream and causing it to fail again.
The motivation is the retry storm failure mode. When a downstream fails, all its callers experience errors simultaneously. Each caller schedules a retry. Without jitter, all callers retry at the same delay (e.g., 1 second), causing a synchronized burst at T+1s. The downstream, which may have just begun to recover, is hit by N concurrent retries at the exact same moment — far exceeding its normal load, and possibly exceeding what it could have handled on its own. The downstream fails again. All N callers schedule another retry; without jitter, all retry at T+1s+2s=T+3s; another synchronized burst. The pattern repeats, with each “burst” potentially knocking the downstream over again as it tries to recover. This is the failure mode Brooker’s article documents in detail and shows how jitter mathematically dissolves: with random delays in [0, base × 2^n], the retries spread across the interval, so the downstream sees a steady recovering load rather than synchronized spikes.
This note develops the pattern: the math of exponential backoff and the three jitter strategies (full, equal, decorrelated), the idempotency requirement that makes retries safe, the classification of errors that should and should not be retried, the retry budget that prevents unbounded retry, the composition with Circuit Breaker Pattern (retry first, breaker on top), the production references (every cloud SDK uses this; Stripe’s idempotency-key API is canon), worked examples showing the difference between naïve retry and production-grade retry-with-jitter, and the pitfalls that make naïve retries cause incidents at scale.
1. When to Use / When Not to Use
When retry-with-backoff is the right call. The defining indicator is a transient failure on an idempotent operation. Both halves of that condition matter. Transient means the failure has a reasonable chance of resolving on retry — network blip, momentary 503, connection reset, throttling. Idempotent means retrying does not duplicate the operation’s effect — GET /resource/123, DELETE /resource/123, an UPSERT on a natural key. With both conditions met, retry is straightforwardly safe and helpful.
A second indicator is the downstream’s recovery characteristic benefits from spaced retries. A downstream that is overloaded recovers when its callers back off; the retry’s spacing gives it time. A downstream that is hard-down (process crashed) recovers on a fixed timescale (process restart, container scheduling); retries spaced through that timescale will eventually succeed when the downstream comes back. A downstream that is permanently down won’t recover; retries fail eventually, and the budget caps the wasted attempts.
A third indicator is the operation has bounded total time budget within which retries fit. A user-facing request with a 1-second deadline can retry once after a 200ms backoff (200ms + retry latency = under budget). A background job with a 24-hour deadline can retry many times across hours. Without a budget, retries can run forever; the budget is what keeps retry from becoming a denial-of-service against the downstream.
When retry-with-backoff is the wrong call. First, when the operation is not idempotent and there is no idempotency key. Retrying a non-idempotent operation duplicates its effect: a charge is processed twice, an email is sent twice, an inventory item is reserved twice. The cost of double-charging is nontrivial: customer complaints, refunds, regulatory issues for some industries. Without idempotency-key support (either in the operation’s natural design or via an explicit idempotency-key header), retries are unsafe and should not be used.
Second, when the failure is not transient. A 400 Bad Request response means the request was malformed; retrying with the same payload will produce the same 400. A 401 Unauthorized means the credentials are wrong; retrying without different credentials will produce another 401. A 404 Not Found means the resource does not exist; retry is futile. Retrying these errors is wasted effort and pollutes metrics. The retry policy must classify errors and retry only the transient class.
Third, when the retry budget would extend beyond the request’s deadline. A user-facing API call with a 1-second deadline does not have time for a retry that takes 10 seconds. The retry’s value is realized only if it fits within the time budget. If it does not, fail fast, return the error to the caller, and let the higher-level system (the user, the upstream service) decide what to do.
Fourth, when the upstream is going to retry anyway. Some systems chain retries: a mobile app retries against the API gateway, the gateway retries against the service, the service retries against the downstream. If each tier retries 3 times, a single user-initiated retry can cause 27 actual attempts. This compounding is the worst form of retry storm; the discipline is “retry at one tier” (typically the lowest, closest to the failure) and let other tiers pass errors through.
2. Structure — Exponential Backoff and Jitter
The classic exponential backoff formula:
delay_n = min(cap, base × 2^n)
where:
delay_nis the delay before retry attempt n (n=0 for the first retry, n=1 for the second, etc.)baseis the baseline delay (typical: 100ms or 1s)2^nis the exponential growth factor (each retry waits 2× as long as the previous)capis the maximum delay (e.g., 60 seconds), preventing the delay from growing unboundedly
For example, with base=100ms, cap=10s, the delays are: 100ms, 200ms, 400ms, 800ms, 1.6s, 3.2s, 6.4s, 10s, 10s, 10s, …
This is the deterministic version; in practice it is always combined with jitter.
2.1 Why jitter — the synchronized retry problem
Without jitter, all callers experiencing the same downstream failure will retry at the same delays. If 1000 clients see a downstream return 503 at T=0, all 1000 retry at T=100ms. The downstream, which may have just started to recover, sees 1000 concurrent requests at T=100ms — likely more than it can handle. It fails. All 1000 retry at T=300ms (100ms+200ms). Synchronized retry storms again.
Brooker’s 2015 article graphed this empirically: in simulations of 100 callers retrying without jitter, the request-rate spikes are tall and synchronized; the downstream’s effective load oscillates between zero (between retry waves) and thousand-fold its normal rate (during retry waves). With jitter, the request rate is approximately steady; the downstream’s load is uniformly distributed across the retry interval.
The mathematical insight: jitter converts a synchronized load (N clients at the same instant) into an asynchronous load (N clients spread across an interval). The downstream’s load goes from a thin tall spike to a wide flat plateau; the plateau is much easier to recover from.
2.2 The three jitter strategies
Brooker’s article analyzes three strategies, comparing their convergence behavior in retry simulations.
Throughout, let expo_n = min(cap, base × 2^n) be the capped deterministic exponential value for attempt n. Every jitter strategy applies the cap first and randomizes within the capped envelope. The formulas below are quoted from Brooker’s 2015 article.
Full jitter:
delay_n = random(0, min(cap, base × 2^n)) # = random(0, expo_n)
random(0, x)is a uniformly distributed random value in[0, x].
The retry’s delay is anywhere between 0 and the capped exponential value. The expected delay is half that value. Some retries happen quickly (low value of random), some happen at the maximum (high value of random); the spread is uniform across the interval.
The benefit: maximally decorrelated retries. The cost: the variance is high — some retries happen very early (close to 0), some at the maximum, with no concentration. If the downstream genuinely needs a few hundred milliseconds to recover, full-jitter retries arriving at “near zero” delay won’t have given it that time.
Equal jitter:
delay_n = (expo_n / 2) + random(0, expo_n / 2)
The retry’s delay is at least half the capped exponential value, plus a random component up to half that value. Total range: [expo_n / 2, expo_n].
The benefit: a guaranteed minimum delay (the downstream is given at least half the backoff before any retry arrives), with randomization on top. The cost: less spread than full jitter, so retry waves still have some concentration. In Brooker’s simulation, equal jitter did slightly more work than full jitter and took much longer, which is why it is rarely the right choice in modern usage.
Decorrelated jitter:
delay_n = min(cap, random(base, prev_delay × 3))
prev_delayis the actual delay used for the previous retry (not the deterministic exponential value); the first retry seedsprev_delay = base.
The retry’s delay is between base and 3× the previous actual delay, then capped. Note the min(cap, …) wrapper is part of Brooker’s published formula and must not be dropped — without it the random walk can drift far above cap. This formulation grows the delay envelope geometrically (similar to exponential) but uses the previous random value as the seed, producing a random walk through delay-space rather than a synchronized exponential.
The benefit: in Brooker’s simulation, decorrelated jitter completed work in less time than full jitter while keeping client work and server load comparably low. The cost: more complex to implement (must track previous delay), and Brooker frames the choice between full and decorrelated jitter as genuinely close — “full jitter uses less work, but slightly more time” — rather than a clear win for either.
2.3 Which jitter to choose
Brooker’s article is careful not to crown a single winner. Its conclusion is that any jittered backoff should be the standard approach for remote clients, and that the choice between full and decorrelated jitter is “less clear” — full jitter “uses less work, but slightly more time,” while equal jitter does slightly more work than full jitter and takes much longer (per Brooker 2015). The practical reading the industry has converged on: full jitter is the sensible default — simple, low server load, competitive completion time — with decorrelated jitter a reasonable alternative when minimizing total completion time matters, and equal jitter rarely the best choice.
Real-world defaults bear this out. The AWS SDK’s “standard” retry mode uses exponential backoff with full jitter — the published formula is delay = random(0, 1) × min(20000 ms, base_delay × 2^retry), with base_delay of 50 ms for transient errors and 1000 ms for throttling errors and a 20-second cap (per the AWS SDK retry-behavior reference, updated-2026 behavior). gRPC’s client-side retry proposal A6 specifies exponential backoff with multiplicative jitter: the first retry waits initialBackoff × random(0.8, 1.2) and the n-th waits min(initialBackoff × backoffMultiplier^(n-1), maxBackoff) × random(0.8, 1.2) — note this is ±20% jitter around the exponential value, not full jitter, and initialBackoff/maxBackoff/backoffMultiplier are all required configuration with no specified default. Resilience4j and Polly support exponential backoff with randomized jitter as configurable strategies.
In modern production systems, “exponential backoff with jitter” most often means full jitter (AWS SDK, many cloud client libraries); the purely deterministic version is essentially never used at scale because of the synchronized-retry problem.
3. Core Principles
Principle 1: idempotency is non-negotiable for retries. A retry that duplicates the operation’s effect is worse than no retry at all. Retrying a non-idempotent payment causes a double charge. Retrying a non-idempotent send-email causes the recipient to receive two emails. Retrying a non-idempotent inventory deduction causes inventory to go negative.
The discipline: every retried operation must be idempotent, either naturally (read operations, set operations) or enforced via idempotency keys. Stripe’s Idempotent Requests API is the canonical reference: every write operation accepts an Idempotency-Key header; Stripe’s servers maintain a per-key cache of “we already processed this request, here is the original response,” and a duplicate request with the same key returns the original response without re-executing. The IETF is standardizing this pattern as a draft RFC.
The implementation requirement: the idempotency-key cache must persist long enough that all reasonable retries find it (Stripe stores keys for 24 hours; some systems store indefinitely). The cache must be transactional with the operation (the key is recorded only if the operation succeeded, or with the failure result, depending on policy). Without proper persistence, idempotency keys provide a false sense of safety.
Principle 2: retry only on transient errors. Errors must be classified before retrying. The classification:
- Transient (retryable): timeouts, connection errors, 5xx (especially 502, 503, 504), 429 Too Many Requests with
Retry-After, network failures. - Permanent (not retryable): 4xx client errors (400, 401, 403, 404, 409, 422), authentication failures, validation errors, “your request was rejected because the data is bad.”
- Special cases: 409 Conflict in some systems is retryable (optimistic-concurrency conflicts can resolve on retry); 503 with no
Retry-Aftermay or may not be retryable depending on the specific service.
A retry policy that retries 4xx errors is broken: each retry will produce the same 4xx, the user waits longer for the inevitable failure, the metrics are polluted with retry-induced errors. The classification must be explicit in the retry policy’s configuration.
Principle 3: bound the retry budget. Retries must terminate. The bounds are:
- Maximum attempts: typically 3-5 for synchronous calls, more (10-20) for asynchronous workflows like webhook delivery.
- Maximum total time: the call’s overall deadline; retries cannot extend beyond it.
- Maximum cumulative load: in some systems, a global “retry budget” prevents the total retry rate from exceeding some fraction of the request rate.
Without bounds, a persistent failure causes infinite retries; the system becomes a denial-of-service against the downstream and itself. The bound is the safety valve.
The Google SRE book’s Chapter 22: Addressing Cascading Failures layers three distinct budgets: a per-request budget (Google uses up to three attempts, after which the failure bubbles up to the caller); a per-client budget (each client tracks the ratio of requests that are retries and retries only while that ratio stays below 10%); and a server-wide budget (e.g. only 60 retries per minute in a process, failing rather than retrying once exceeded). The chapter also warns explicitly that retrying at multiple layers multiplies: if a backend, frontend, and JavaScript layer each issue three retries (four attempts), a single user action can produce up to 4³ = 64 attempts on an overloaded database. At Google scale these budgets prevent retry-induced cascading failure where a struggling downstream is buried under exponentially-growing retries.
Principle 4: retry at one tier of the call stack. As mentioned in §1, retries compound across tiers: a 3-retry policy at the gateway and a 3-retry policy at the service produces 9 effective attempts. The discipline: choose one tier (typically the closest to the failure) to do the retrying; other tiers pass errors through.
The reason: amplification grows multiplicatively, but the value of retries grows additively. Three retries at the closest tier can typically catch the same transient failures that nine retries across three tiers would catch — but with one-third the load on the downstream during recovery.
4. Variants
4.1 Linear backoff
delay_n = base × n
Delays grow linearly, not exponentially. Useful for downstreams whose recovery time is roughly constant regardless of how recently they failed; rare in practice. Most production systems prefer exponential because the early retries are cheap (low n means low delay) while the later retries give significant time for recovery.
4.2 Constant backoff
delay_n = base
Every retry uses the same delay. Useful for short retry sequences where the delay doesn’t need to grow (e.g., 3 retries with 100ms each). Often combined with jitter (delay = base × random(0.5, 1.5)) to prevent synchronization.
4.3 Exponential backoff with cap (the standard)
delay_n = min(cap, base × 2^n)
The deterministic version, which is almost always further combined with jitter (§2.2). The cap prevents the delay from growing unboundedly; without the cap, after 20 retries the delay exceeds days, which is operationally useless.
4.4 Server-controlled retry via Retry-After
When the server returns 429 Too Many Requests or 503 Service Unavailable, it may include a Retry-After: 5 header indicating “wait 5 seconds before retrying.” A well-behaved retry policy honors Retry-After: this is the server’s explicit signal of when it expects to be ready, and ignoring it amplifies the storm.
The interplay with backoff: if Retry-After is present, use it (override the exponential schedule). If absent, fall back to exponential backoff with jitter. The pattern allows the server to throttle clients precisely when it has accurate recovery information.
4.5 Retry budget / token-bucket retry
A more sophisticated variant where retries consume from a budget (often a token bucket — see Token Bucket). The bucket refills at a configured rate (e.g., 10% of the regular request rate); retries are allowed only if a token is available. Under sustained downstream failure, the bucket empties; retries are throttled at the bucket’s refill rate; the system cannot retry-storm.
This is the “Google-scale” retry discipline: in addition to per-call backoff, a global cap on the retry rate prevents the aggregate retry load from exceeding what the downstream can handle.
4.6 Long-horizon retry (webhooks, durable jobs)
In contexts where the retry can span hours or days — webhook delivery, asynchronous job processing — the retry sequence is longer and uses larger delays. Webhook Delivery System Design describes a typical webhook retry schedule: 5s, 30s, 5min, 30min, 1hr, 6hr, 12hr, 24hr (often with jitter applied to each delay). The total span is many hours; the goal is “if the receiver comes back any time in the next day, our retry will eventually arrive at a moment when they are up.”
This is a separate operational regime from synchronous retry: the budget is huge, the storage is durable (persistent retry queue), the implementation is via background workers rather than in-call loops. But the underlying math is the same — exponential growth with jitter.
4.7 Worked Example — DynamoDB throttling and the three approaches
A service makes 1000 writes per second to DynamoDB. DynamoDB enters provisioned-throughput throttling: it returns ProvisionedThroughputExceededException (429-equivalent) for some fraction of requests. The throttling rate is 30%. The service must handle these errors via retry.
Approach A: naïve retry (no backoff). On error, immediately retry. DynamoDB returns 30% errors → service retries → DynamoDB sees 1300 RPS (1000 + 300 retries) → throttling rate climbs (the service is now over its provisioned capacity even harder) → more errors → more retries. Within seconds, the retry loop has amplified the load to several thousand RPS, and DynamoDB is throttling 80% of requests. The service is in a tight retry loop, making no forward progress. This is the failure mode every serious retry implementation guards against.
Approach B: exponential backoff without jitter. On error, retry with delay 100ms × 2^n. The service’s retries are exponentially spaced — first retry at 100ms, second at 200ms, third at 400ms. But all 300 throttled requests retry at exactly 100ms (synchronized). DynamoDB sees a spike at T+100ms of 300 retries on top of the next 100 normal requests = 400 RPS for that moment, exceeding capacity, more throttling. Then a spike at T+300ms (100ms+200ms), then T+700ms. The retries arrive in synchronized waves; each wave overshoots capacity. The throttling persists.
Approach C: exponential backoff with full jitter (the production answer). On error, retry with delay = random(0, 100ms × 2^n). The 300 throttled requests scatter across the [0, 100ms] interval; some retry at 5ms, some at 95ms, evenly distributed. DynamoDB sees a smoothly elevated load (1000 normal + 300 retries spread over 100ms) which is closer to the real throughput. The retries continue for a few seconds; the storm dissolves; throttling stabilizes around the provisioned rate; the service catches up.
This is exactly the scenario Brooker’s article documents. The AWS SDK’s default retry behavior implements this pattern; a service using the SDK gets the production-grade retry behavior automatically. A service rolling its own retries (or using a misconfigured library) may end up in approach A or B and discover the failure only under load.
5. Real-World Examples
AWS SDKs. All AWS client libraries (Java, Python boto3, JavaScript, Go, Rust, etc.) implement exponential backoff with full jitter as the default retry behavior, exposed through three modes — standard (the default), adaptive, and legacy (per the AWS SDK retry-behavior reference). Standard mode uses delay = random(0,1) × min(20000 ms, base_delay × 2^retry) with a 50 ms base for transient errors, a 1000 ms base for throttling errors, and a 20-second cap; the default AWS_MAX_ATTEMPTS is 3 (one initial request plus two retries — DynamoDB defaults to 4 with a 25 ms transient base). Standard mode also carries a retry quota: a 500-token bucket where each transient retry costs 14 tokens and each throttling retry costs 5; when the bucket empties (sustained failure beyond roughly 22% transient / 32% throttling), the SDK fails fast instead of retrying. Adaptive mode adds a per-client-instance rate limiter that can delay even the initial request when throttling is detected; AWS explicitly does not recommend it as a general default because throttling on one resource slows all requests from the client.
Google Cloud client libraries. Same pattern: exponential backoff with jitter, configurable. Documented in the Architecture Framework reliability section.
Azure SDKs. Same pattern, configured via Polly under the hood for the .NET SDK.
Stripe API and idempotency keys. Stripe is the canonical reference for write-operation retries via Idempotency-Key. Every write endpoint accepts the header; Stripe stores responses keyed by the value for 24 hours; duplicate requests with the same key return the original response. The pattern allows clients to retry safely without risk of duplicate operations. Stripe’s documentation explicitly recommends exponential backoff with jitter for retries.
gRPC client-side retries. gRPC Proposal A6 defines configurable client-side retry policies with exponential backoff and ±20% multiplicative jitter (× random(0.8, 1.2) on each computed delay). The configuration is via the service config (static JSON or distributed through xDS); maxAttempts, initialBackoff, maxBackoff, and backoffMultiplier are all required fields — the proposal specifies no default multiplier. A6 also defines a hedging policy (send parallel copies at hedgingDelay intervals, cancel the rest when one succeeds), which requires the server to handle duplicate requests safely.
Resilience4j Retry. The Java retry library; supports exponential backoff with full jitter, retry-on-exception predicates, retry budgets, retry callbacks. Composed with circuit breaker and bulkhead via decorators.
Polly Retry. The .NET retry library; same primitives. The composition with circuit breaker is canonical: pipeline.AddRetry(...).AddCircuitBreaker(...) — retries first, breaker as outer protection.
Kubernetes job restart policies. Kubernetes has retry-with-backoff at multiple layers: pod restart policies (exponential backoff with cap, capped at 5 minutes), job retry behavior, deployment rollouts. The patterns are bake into the platform.
TCP exponential backoff. The original. TCP connection retries (SYN retries) and TCP RTO (retransmission timeout) both use exponential backoff with jitter. The discipline of “double the delay on each retry” comes from networking before it migrated to application-level retries.
Ethernet collision backoff. Even older — this is the origin (per Metcalfe & Boggs 1976, standardized in IEEE 802.3 in 1983). Ethernet’s CSMA/CD collision resolution (the “binary exponential backoff” algorithm) has each colliding station, after the n-th collision, wait a random integer number of slot times drawn uniformly from [0, 2^n − 1] before retransmitting (the range is truncated — capped, typically at 2^10 − 1 slots after ten collisions). Choosing a uniform random delay from a doubling range is precisely “exponential backoff with full jitter” — the algorithmic ancestor of all modern application-level retry-with-jitter strategies, predating the distributed-systems framing by four decades.
6. Composition with Circuit Breaker
The relationship between retry and Circuit Breaker Pattern deserves careful articulation because they are sometimes confused.
Retry: this individual call failed; let me try again, the failure may be transient.
Circuit breaker: too many recent calls have failed; let me stop trying for a while, the failure is systematic.
Both deal with failure, but at different scopes. Retry is per-call: the failure of this call may be a blip; another attempt may succeed. Breaker is per-set-of-calls: the failure of many recent calls indicates the downstream is sick; new attempts will likely also fail; stop attempting.
The composition: retry first, breaker on top. Each call attempts up to N retries with exponential backoff and jitter. The circuit breaker counts the final outcome of each call (after retries). When the failure rate of final outcomes crosses the breaker’s threshold, the breaker trips: new calls short-circuit, no retries are attempted, the breaker’s cooldown kicks in.
This composition is the production-grade pattern in every major resilience library:
- Resilience4j:
Decorators.ofSupplier(...).withRetry(...).withCircuitBreaker(...)— note retry is inside breaker, meaning breaker wraps the entire retry sequence and the breaker counts post-retry outcomes. - Polly:
pipeline.AddRetry(...).AddCircuitBreaker(...)— similar ordering. - AWS SDK adaptive retry: combines retry budget (a circuit-breaker-like throttle on retry rate) with exponential backoff and jitter.
The reason to layer this way: if the breaker counted every retry attempt as a separate outcome, transient failures requiring 2-3 retries to succeed would inflate the breaker’s failure count and cause spurious trips. By counting only the final outcome, the breaker fires only when retries themselves are no longer helping — which is the right signal for circuit-breaking.
7. Tradeoffs
| Choice | Pro | Con | When chosen |
|---|---|---|---|
| Exponential backoff (no jitter) | Predictable timing | Synchronized retry storms | Never for production |
| Full jitter | Maximally decorrelated; simple | Wide variance | Default; Brooker-recommended |
| Equal jitter | Guaranteed min delay | Less spread than full | Some narrow use cases |
| Decorrelated jitter | Best at high concurrency | More complex | Very-high-concurrency systems |
| Constant backoff | Simple | No exponential safety margin | Short retry sequences |
| Linear backoff | Predictable growth | Less aggressive than exponential | Rare |
| Retry budget | Caps amplification | More config to tune | High-scale systems |
| Retry-After header | Server-controlled | Requires server support | Standard for 429/503 |
| In-call retry | Synchronous; simple | Limited by deadline | Fast-recovery downstreams |
| Durable retry queue | Long-horizon retries | Infrastructure overhead | Webhook, batch workflows |
| Per-attempt timeout | Bounds individual retry | Must coordinate with deadline | Always |
| 3-retry budget | Standard for synchronous | May not be enough for some spikes | Default for synchronous |
| 10+-retry budget | Catches longer outages | Risk of huge total delay | Async / background |
8. Migration Path
Adopting retry-with-backoff in a service that has naïve or no retries:
Step 1: identify retry candidates. Every network call — HTTP, gRPC, database, cache, message broker, third-party API. For each, determine: is this operation idempotent (or supported by an idempotency key)? Is the downstream prone to transient failures? If yes to both, retry is warranted.
Step 2: classify errors. For each call, list the error responses and classify each as retryable or not. 5xx server errors and timeouts are retryable; 4xx client errors are not (with possible exceptions like 409, 429); domain-specific errors may need custom classification.
Step 3: pick a library. Java/Kotlin → Resilience4j Retry. .NET → Polly. Go → cenkalti/backoff or built into the AWS / GCP client libraries. Node → cockatiel or axios-retry. Python → tenacity. Most cloud-vendor SDKs already include retry-with-backoff; check their documentation before rolling your own.
Step 4: configure backoff parameters. Base delay (typically 100ms-1s), cap (typically 10s-60s), max attempts (typically 3-5 for synchronous), jitter strategy (full jitter is usually the right choice). Tune based on the downstream’s expected recovery time.
Step 5: ensure idempotency. For non-idempotent operations, implement idempotency keys. Stripe’s pattern is the reference. Without idempotency, retries are unsafe.
Step 6: bound the total time. Compute the worst-case retry sequence’s total delay. If it exceeds the request’s deadline, reduce max attempts or shorten base delay. The retry must fit within the deadline.
Step 7: compose with circuit breaker. Add a circuit breaker around the retry-protected call. The breaker counts post-retry outcomes; trips on systematic failure; gives the downstream room to recover when retries are not helping.
Step 8: instrument. Per-operation metrics: retry count distribution, time-to-success distribution, retry-induced load multiplier, retry budget utilization. Without metrics, retries are operationally invisible.
Step 9: test failure scenarios. Inject downstream failures and verify retry behavior. Verify that retries are spaced (jitter is working), that they terminate (budget is enforced), that they compose with the circuit breaker (the breaker trips when retries exhaust).
Step 10: iterate. Real-world failure modes often differ from expectations. Adjust parameters as you observe production behavior — base delay too low? Increase. Cap too short? Extend. Max attempts too few? Add more (within deadline budget).
9. Pitfalls
Pitfall 1: no jitter (synchronized retry storms). As described in §2, retries without jitter synchronize across clients, amplifying load on the recovering downstream. The downstream sees thin tall spikes instead of smooth recovery; retries fail again; cycle repeats. This is the canonical pitfall the entire jitter discipline exists to prevent.
Pitfall 2: retry budget too high (DDoS your own downstream). A service with 1000 RPS and a 10-retry budget can produce 11,000 RPS of retry load during sustained downstream failure — 10× normal traffic, far exceeding the downstream’s capacity. The downstream cannot recover under this load. The fix: cap retries at a small number (3-5 for synchronous calls); use a global retry budget (max % of total request rate) for additional safety; compose with circuit breaker.
Pitfall 3: retrying non-idempotent operations. Charging a card twice. Sending an email twice. Reserving inventory twice. The user-visible cost is significant — duplicate charges trigger refunds, complaints, regulatory issues. The fix: idempotency keys for every retried write operation. Stripe’s Idempotency-Key is the reference pattern; many APIs now support this natively.
Pitfall 4: retrying on permanent errors. Retrying 400 Bad Request will produce 3 more 400s; retrying 404 Not Found will produce 3 more 404s. The user sees a 4× slower error response with no benefit. The fix: explicit error classification in the retry policy; retry only on the transient class (5xx, timeouts, connection errors, 429 with Retry-After).
Pitfall 5: retry compounding across tiers. Mobile app retries 3× → API gateway retries 3× → service retries 3× → 27 actual attempts. Plus the mobile app’s user might tap retry, multiplying further. The fix: retry at one tier (typically closest to the failure); other tiers pass errors through. Define a clear retry-tier policy across the architecture.
Pitfall 6: retry without timeout. A retried call without a per-attempt timeout will hang until OS-level network limits kick in (sometimes minutes); the retry budget never engages because the first attempt never returns. The fix: every call has a per-attempt timeout (see Timeout and Deadline Pattern); the timeout is what converts “hung call” into “retryable failure.”
Pitfall 7: retry-induced thread starvation. Retries hold caller threads while waiting for the backoff delay. With a 10-retry budget and exponential delays adding up to minutes, calls can hold threads for the entire span. Other calls cannot proceed; thread pool exhausts. The fix: bulkhead per dependency (see Bulkhead Pattern); use asynchronous / non-blocking retry implementations (Resilience4j’s reactive support, Polly’s async pipelines); bound total retry time well within thread-pool turnover budgets.
Pitfall 8: ignoring Retry-After. Server returns 429 with Retry-After: 60 — meaning “wait 60 seconds.” Retry policy uses its exponential schedule (e.g., 100ms first retry) and immediately retries. Server is still rate-limiting; returns another 429. Naïve retry is now hammering a server that explicitly told it to wait. The fix: the retry policy must check for and honor Retry-After; if present, use it (override the exponential schedule). Most production libraries do this automatically.
Pitfall 9: retrying on success-but-slow. A request returns 200 OK after 20 seconds, well past the timeout. The timeout fires; the caller retries. The retry succeeds quickly (the downstream has recovered). But the original request also succeeded — the response just arrived after the timeout. The result: the operation has executed twice (once originally, once on retry). For non-idempotent operations, this is the double-execution bug from a phantom success. Mitigations: idempotency keys (retry is safe); design timeouts conservatively (timeout > p99 latency × margin); track in-flight calls server-side and de-duplicate.
Pitfall 10: retry loops in error-handling code. A subtle bug pattern: the retry’s exception-handling logic itself fails (e.g., logging the error throws because the logger is misconfigured), and the failure handler retries the entire operation, which retries the failed logger, which retries… a recursive retry loop. The fix: defensive coding in retry-handler code; catch-all exception handling that does not re-trigger retry; testing of the retry’s failure paths.
Pitfall 11: retry in long-running transactions. A retry within a database transaction can extend the transaction’s duration unboundedly, holding locks across retries. Other transactions queue; the database’s lock table grows. The fix: retries must terminate before the transaction’s lock duration becomes problematic; consider moving retries outside the transaction (commit, retry, then start a new transaction for the fix-up work).
10. Comparison with Sibling Patterns
Retry vs Circuit Breaker Pattern. Retry: try again, perhaps the failure was transient. Breaker: stop trying, the failure is systematic. They compose: retry first (handle individual transient failures), breaker on top (stop retrying when retries are no longer worth attempting). The standard composition: each call attempts N retries; the breaker counts post-retry outcomes; trips on systematic failure. Detailed §6 above; further detail in Circuit Breaker Pattern §11.
Retry vs Timeout and Deadline Pattern. Timeout: bound an individual call’s duration. Retry: re-attempt after failure. Each call has a timeout; a timeout-induced failure is a retry trigger. The retry budget must fit within the overall request deadline (the timeout is per-attempt; the deadline is total). Composition: per-attempt timeout + retry-with-backoff + total deadline cap.
Retry vs Bulkhead Pattern. Bulkhead: limit concurrency. Retry: re-attempt failures. They compose: retries within the bulkhead’s concurrency limit; the bulkhead protects against retry-induced concurrency spikes. A retry that consumes a permit during its delay (waiting for the next attempt) ties up bulkhead capacity; some implementations release the permit during the wait and re-acquire on retry.
Retry vs Webhook Delivery System Design retry semantics. Webhook delivery uses long-horizon retries (hours to days) via a durable retry queue. The math is the same (exponential backoff with jitter, budget); the implementation is asynchronous and persistent. Synchronous in-call retries are bounded by request deadlines; webhook retries are bounded only by configurable max age (e.g., 24 hours, after which the webhook is dead-lettered).
Retry vs Idempotency keys (Idempotency in Distributed Systems). Retry creates the duplicate-request problem; idempotency keys solve it. The two are joint requirements: retries need idempotency to be safe; idempotency keys are useful only because retries exist. Stripe’s API combines both: every write accepts an idempotency key; SDKs retry-with-backoff transparently.
Retry vs Saga Pattern. Saga steps may be retried; saga compensations may be retried. Both are subject to the same idempotency requirement and exponential-backoff discipline. Workflow engines (Temporal, Cadence) bake retry-with-backoff into step execution; the saga’s own logic is unchanged.
Retry vs adaptive concurrency control. Adaptive concurrency (e.g., Netflix’s concurrency-limits library) adjusts the in-flight concurrency based on observed latency, similar to TCP’s congestion control. It is a different mechanism from retry: it controls how many requests are in flight, not whether to retry on failure. They compose: concurrency limits prevent the caller from overwhelming the downstream; retries handle the failures that still occur.
Retry vs hedging. Hedging is “send the request twice (or more) in parallel; use whichever returns first.” It trades load amplification for tail-latency reduction. Retry is sequential: try once, on failure try again. Hedging is a different optimization (latency, not failure recovery), but the load-amplification concern is similar; gRPC’s hedging policy requires the receiver to handle duplicate requests safely, just like retries.
11. Common Interview Discussion Points
- “How do you handle network failures?” Layered defense: per-call timeout, retry-with-backoff (exponential, with jitter, on transient errors), circuit breaker around retries, fallback for graceful degradation. The retry pattern with jitter is the canonical answer; mentioning jitter is the senior-level detail.
- “Why do you need jitter?” Synchronized retries amplify load. Without jitter, all clients retry at exactly the same time, creating thin tall spikes that overwhelm the recovering downstream. With jitter, retries spread across the interval, smoothing the load. Brooker’s article is the standard reference.
- “What are the three jitter strategies?” Full jitter (delay = random(0, base × 2^n)), equal jitter (delay = base × 2^n / 2 + random(0, base × 2^n / 2)), decorrelated jitter (delay = random(base, prev × 3)). Brooker’s analysis: full jitter is the default winner for most workloads; decorrelated for very-high-concurrency.
- “What’s the relationship between retry and idempotency?” Retries duplicate calls; non-idempotent operations break under duplication (double charge, double email). Either the operation is naturally idempotent, or it must support idempotency keys. Stripe’s
Idempotency-Keyis the canonical reference. - “How does retry compose with circuit breaker?” Retry first, breaker on top. The breaker counts post-retry outcomes; trips when retries are no longer succeeding. Resilience4j and Polly both compose this way by default.
- “What errors should you retry?” Transient: 5xx (especially 502, 503, 504), timeouts, connection errors, 429 with Retry-After. Permanent: 4xx client errors. Retrying permanent errors is wasted effort; retry policy must classify.
- “What’s a retry budget?” Maximum retry count, maximum total time, or maximum retry rate (as fraction of normal request rate). Without a budget, retries can DDoS the downstream. Standard for synchronous: 3-5 retries; for async: 10-20.
- “What’s the difference between in-call retry and durable retry?” In-call: retry within the original request’s context, bounded by the request’s deadline. Durable: retry from a persistent queue, can span hours or days. Webhooks use durable retry; user-facing API calls use in-call retry.
- “How does the AWS SDK handle retries?” Default exponential backoff with full jitter, configurable max attempts (default 3). Adaptive mode adds throttling-aware backoff and a retry budget. Documented in AWS SDK reference guides.
- “What’s the Retry-After header?” Server-controlled retry timing: the server returns 429 or 503 with
Retry-After: <seconds>indicating when to retry. Retry policy must honor this; ignoring Retry-After amplifies the storm. - “Why not retry indefinitely?” Persistent failure causes infinite retries; the system DDoSes the downstream and itself. Bound the retries (max attempts, max total time, max rate). Compose with circuit breaker for systematic failure.
- “How do you prevent retry compounding across tiers?” Retry at one tier (typically closest to the failure); other tiers pass errors through. A 3-retry policy at three tiers produces 27 effective attempts; this is the worst form of retry storm.
12. Open Questions
The base delay and cap are workload-dependent rather than universal, and the verified production defaults make the spread concrete: the AWS SDK uses a 50 ms base for transient errors, 1000 ms for throttling, and a 20-second cap; DynamoDB tunes the transient base down to 25 ms for its low-latency profile (per the AWS SDK retry-behavior reference). The often-quoted “100 ms base, 10 s cap” is a reasonable starting point, but the right values follow from the downstream’s measured recovery characteristic — a database that recovers in tens of milliseconds wants a small base; a throttled API that needs a second to refill capacity wants a base near a second. Tune from baseline measurement, not from a memorized constant.
The choice between full and decorrelated jitter is genuinely close rather than settled. Brooker’s own framing is that full jitter “uses less work, but slightly more time” while decorrelated jitter completes faster (per Brooker 2015); neither dominates. The industry default is full jitter for its simplicity and low server load, with decorrelated reserved for cases where minimizing total completion time under heavy contention is the priority. Below are the genuinely open design questions where this vault has not yet pinned a primary source:
- For very-low-latency paths (microseconds), is even a small base delay too long? Retry-without-delay variants exist for these contexts but lose the back-off safety property.
- How should retries interact with adaptive rate limiting? Both reduce load on a struggling downstream; they may double-count or interfere. AWS’s adaptive retry mode is one concrete answer (a per-client rate limiter on top of the retry quota), but the general composition is underspecified.
- Is there a clean way to share retry state across distributed clients (so the “retry budget” is global rather than per-client)? AWS’s retry quota is explicitly per-client-instance, not shared across processes or hosts; service meshes are starting to offer cross-client coordination, and the architecture is still evolving.
9.A The Stripe Retry Pattern — A Reference Implementation
Stripe’s API client libraries are widely studied as reference implementations of the retry pattern. Their design choices balance simplicity (developers must use the SDK without deep configuration) with robustness (production payment systems cannot tolerate retry-induced bugs).
The Stripe SDK retry behavior. Default: retries on connection errors and 5xx responses; up to 3 attempts; exponential backoff with full jitter; per-request Idempotency-Key (auto-generated by SDK if not specified); honors Retry-After header for 429 responses. The configuration is mostly hidden from developers; SDK does the right thing by default.
The idempotency-key auto-generation. Stripe’s SDK generates a per-request UUID and includes it as the Idempotency-Key header automatically. The application code doesn’t need to think about it. If the application wants to control the key (for cross-process deduplication), it can supply its own.
The behavior on retry: if the original request reached Stripe but the response was lost, the retry with the same idempotency key returns Stripe’s recorded response (the original outcome) without re-executing. The application sees a single result regardless of how many retries occurred. This is the core safety property: at-most-once semantics for write operations despite at-least-once delivery from the SDK’s perspective.
The 24-hour key window. Stripe stores idempotency keys for 24 hours. After that, the cache expires; a retry with the same key after 24 hours would re-execute. This is rarely a problem in practice (real retries happen within seconds-to-minutes), but applications doing very-long-running retries must be aware.
The error classification. Stripe’s SDK classifies errors:
- Connection errors and 5xx: retryable.
- 429 Too Many Requests: retryable, with
Retry-Afterhonored. - 400 / 401 / 403 / 404 / 422: not retryable (they indicate the request was invalid).
- Specific error codes within 4xx (e.g.,
card_declined): not retryable in the SDK; the merchant must take corrective action.
The classification is encoded in the SDK; developers don’t need to think about it. The right errors are retried; the wrong ones aren’t.
The composition with circuit breaker. Stripe’s SDK doesn’t include a circuit breaker by default; merchants implementing retries are expected to add one in their application code if they want one. The reasoning: Stripe’s API is highly available (99.99%+); a circuit breaker is rarely needed. For high-volume merchants, adding a circuit breaker around Stripe calls is recommended.
The lesson for non-Stripe APIs. The Stripe pattern (auto-idempotency, sensible defaults, error classification, jitter, retry budget) is a good template for any API client library. Many newer APIs adopt similar patterns. Implementing your own client library for an API that lacks SDK retry support requires recreating these patterns; the Stripe code (open-source SDKs in many languages) is a useful reference.
9.B The Webhook Retry Pattern — Long-Horizon Retries
Webhook delivery (see Webhook Delivery System Design) uses a long-horizon retry pattern fundamentally different from synchronous in-call retry. The differences are worth articulating.
The setup. A platform (Stripe, Shopify, GitHub) emits webhook events to merchant-supplied URLs. The merchant’s webhook receiver may be unavailable for various reasons: deployment in progress, infrastructure issue, code bug. The platform must deliver the webhook eventually, which may take minutes, hours, or days.
The retry schedule. Typical schedules: 5s, 30s, 5min, 30min, 1hr, 6hr, 12hr, 24hr. Each retry waits the configured duration; jitter is applied to each delay; total horizon is ~24 hours; max attempts ~10.
The persistence. Each webhook to deliver is stored in a durable queue with its delivery state. If the platform restarts, the queue persists; deliveries continue. This contrasts with synchronous retries where the retry state lives in the calling process and is lost on restart.
The dead-letter pattern. After the retry budget is exhausted, the webhook is moved to a dead-letter queue. Operators are alerted; the merchant can be notified. The webhook may be manually replayable when the receiver is fixed. This is the long-horizon equivalent of “retries failed, fall back.”
The idempotency assumption. Webhook delivery is at-least-once; receivers must handle duplicates. The platform sends an Idempotency-Key (or equivalent) header with each event; receivers de-duplicate by storing seen keys. Without this, retries can cause duplicate processing on the receiver side.
The contrast with synchronous retry. Synchronous: minutes-to-seconds horizon, in-process state, immediate user impact. Webhook: hours-to-days horizon, durable state, user impact deferred. Both are forms of retry-with-backoff but at different operational scales.
The pattern is implemented across many platforms (Stripe webhooks, Shopify webhooks, GitHub webhooks, AWS EventBridge). The schedules vary slightly but the structure is the same: exponential backoff, jitter, durable state, dead-letter queue, max horizon. See Webhook Delivery System Design for the full architectural treatment.
10.A Variant Deep Dive — Adaptive Retry and Token Bucket Budgets
Beyond §4 variants, an important production pattern is the adaptive retry — the retry budget that adjusts based on observed conditions. The AWS SDK’s adaptive retry mode is a good worked example.
The motivation. Static retry budgets (max 3 attempts) work but are blind to overall system conditions. If the downstream is healthy, 3 attempts are mostly wasted (the first usually succeeds). If the downstream is overloaded, even 3 attempts amplify load. Static configurations cannot adjust.
The token bucket retry budget. Maintain a token bucket per (caller, callee) edge. Successes contribute tokens (refill); failures or retries consume tokens. When the bucket is empty, no retries are allowed; the call fails immediately. The bucket’s size and refill rate determine the maximum sustained retry rate.
The math: if successes refill the bucket at rate r_success and retries consume at rate r_retry, the steady state is r_retry < r_success for retries to be available. Brooker’s article and the AWS SDK docs describe this; the parameters are tuned so that a healthy downstream allows most retries (bucket is mostly full), while a degraded downstream throttles retries (bucket runs empty quickly).
Implementation via existing primitives. The token bucket pattern (see Token Bucket) provides the rate-limiting primitive. Each call before retrying checks the bucket; if a token is available, take it and retry; otherwise, fail. The bucket refills at the configured rate.
Adaptive backoff based on observed success rates. Some implementations adjust the base delay based on observed conditions. After a sequence of failures, increase the base; after a sequence of successes, decrease it. AIMD (Additive Increase, Multiplicative Decrease) — the same congestion-control algorithm as TCP — is the canonical pattern: increase delay slowly when things go wrong, decrease delay fast when things recover.
This is computationally light (just tracking recent success rates and adjusting a parameter) and provides better behavior than static configurations for highly-variable workloads. The cost is added complexity; static configurations are simpler to reason about.
Adaptive retry in cloud SDKs. AWS draws a sharper line than older write-ups suggest (per the AWS SDK retry-behavior reference, updated-2026 behavior). The token-bucket retry quota belongs to standard mode, not just adaptive: a 500-token bucket where each transient retry costs 14 tokens and each throttling retry costs 5 (transient failures are penalized harder because they more often signal a service-wide problem), refilled on success. Adaptive mode adds something different on top — a client-side rate limiter that tracks throttling responses and can delay or block even the initial request, not just retries. The two mechanisms compose: standard’s quota makes retries fail fast under sustained failure; adaptive’s rate limiter proactively slows the whole client when a single resource is throttling. AWS recommends standard mode for general use and adaptive only for single-resource, throttling-heavy, latency-tolerant workloads.
The contrast with simpler patterns. Static exponential backoff with jitter is the conservative, simple, “safe” choice. Adaptive retry is the optimization on top, useful when call rates are high enough that static budgets meaningfully constrain throughput. For most services, static is enough; for high-RPS, latency-sensitive services with bursty failures, adaptive offers measurable improvement.
10.B Sequential vs Parallel Retry Composition
When a service makes multiple downstream calls, retry composition becomes complex. The two patterns:
Sequential retry. Each call has its own retry sequence; the calls happen one after another. If call A fails and retries, calls B and C wait. The total time can be dominated by retries on a single failing call.
Parallel retry. Each call retries independently in parallel. If A fails while B and C succeed, only A is retried; B and C are already done. The total time is the maximum of (B duration, C duration, A’s retry sequence duration).
For independent parallel calls, parallel retry is the right pattern: the failures don’t block each other; the system makes progress on what’s working. For sequential dependent calls (A’s result is input to B), sequential is forced; A must succeed before B can be attempted.
The implication: code structure matters. A naive sequential implementation that calls A, then B, then C, with retries on each, has total worst-case time = retry_total_A + retry_total_B + retry_total_C. A parallel implementation that fires all three simultaneously has worst-case max(retry_total_A, retry_total_B, retry_total_C) — possibly an order of magnitude faster for the failing-call case.
When deadlines are tight, parallel retry is essential. When the calls are dependent, parallel is impossible. Mixed patterns (some parallel, some sequential based on dependencies) are common in production.
Coordinated retry budgets across parallel calls. When 3 calls retry in parallel, each consumes from the per-edge retry budget. If the budget is shared (per caller, all edges), the budget can be exhausted by one degraded edge, blocking retries on healthy edges. The fix: per-edge budgets, not shared. The implementation: maintain a separate token bucket per (caller, callee) edge.
11.A Retry Storms — A Detailed Pathology
The Brooker article describes retry storms qualitatively; understanding the pathology in detail is essential to designing systems that resist them.
The setup. A downstream service S has typical capacity 1000 RPS. It is suddenly degraded — say, a database connection issue causes its capacity to drop to 500 RPS. Its callers, in aggregate, send 1000 RPS (matching its typical capacity). Now S is over-capacity; about 50% of requests fail with 503 or timeout.
Without retry, the pathology is bounded. Callers see 50% failure; they propagate errors upstream. End users see partial degradation. S continues at 500 RPS; its load is bounded by the arrival rate. The system is degraded but stable.
With naïve immediate retry, the pathology is unbounded. Every failed request is retried immediately. S now sees 1000 RPS arrivals + 500 RPS retries (the 50% that failed) = 1500 RPS. S is even more over-capacity; its failure rate climbs to 67%. The next round of retries is 1000 RPS retries (67% of 1500). S now sees 1000 + 1000 = 2000 RPS arrivals; failure rate 75%. Within seconds, S is at 4x capacity, failing 90%+ of requests. The retry loop has amplified the load to many multiples of normal, far exceeding any reasonable downstream capacity.
With exponential backoff but no jitter. First retry waits 100ms. All 500 failed requests retry at exactly T+100ms. S sees a thin spike: 500 retries arriving simultaneously, on top of 100 normal arrivals (assuming roughly uniform arrival distribution at 1000 RPS = 100 every 100ms). At T+100ms, S sees 600 in-flight requests in a narrow window; this exceeds its capacity even more than steady-state did; failure rate climbs. At T+300ms (next retry round), the same thing happens. The system oscillates between thin tall spikes (failure) and quiet periods (no retries). Recovery is slow because every spike re-degrades S.
With exponential backoff and full jitter. First retry waits random(0, 100ms). The 500 failed requests are spread uniformly across [0, 100ms]; that’s 5 retries per ms on average. Combined with the 100/100ms = 1 normal arrivals per ms, S sees 6 in-flight requests per ms — close to its degraded capacity. The retry is smooth; S is at its maximum sustained rate but not beyond. Eventually S’s capacity recovers; failure rate drops; the steady-state stabilizes.
The mathematical observation: synchronization concentrates load in time; randomization spreads load. A degraded downstream cannot handle synchronized load (the spike exceeds its instantaneous capacity); it can handle randomized load (the spread is below its sustained capacity). Jitter is what converts the former to the latter.
Real-world amplification. Brooker’s article and the AWS Builder’s Library piece describe scenarios where retry storms amplified load by 10x or more, far exceeding any reasonable safety margin. The downstream cannot recover under such load; the only break is when callers give up entirely (max retry budget) or when an external safety net (circuit breaker, rate limiter) kicks in.
The role of circuit breakers in retry storm prevention. A circuit breaker on top of retry breaks the storm when the downstream is genuinely degraded. After a few rounds of retries failing, the breaker trips; further calls short-circuit; the retry load drops to zero. The downstream is now relieved of all caller load; it can recover; after the cooldown, the breaker probes; if S has recovered, normal operation resumes. The breaker is the “give up entirely” mechanism that retries alone do not have.
The composition is essential: retry-with-backoff-and-jitter handles the individual transient failure case; circuit breaker handles the systematic degradation case. Without both, retry storms are inevitable in any system with non-trivial downstream coupling.
12.1 The Brooker Simulation — A Closer Look at the Math
Marc Brooker’s 2015 article is canonical; the simulations he ran are worth understanding because they make the case for jitter quantitatively rather than rhetorically.
Setup. N clients (typically 1000+ in the simulation) make requests to a downstream service. The downstream has limited capacity (the simulation pegged at, say, 50 concurrent requests). When the downstream is over capacity, it returns errors. Clients that receive errors retry with one of: no backoff, exponential backoff without jitter, exponential backoff with full jitter, or exponential backoff with decorrelated jitter. The simulation runs for a fixed time and measures: total requests sent (proxy for load amplification), total successes (the goal), total time-to-completion (latency).
Key results. No backoff is catastrophic — total requests sent is many multiples of N, the downstream stays in overload, total successes is low. Exponential backoff without jitter improves the load amplification but produces visible periodic spikes — every retry round, all clients hit the downstream simultaneously, momentarily exceeding capacity again. Exponential with full jitter produces a smooth load profile; the downstream sees roughly constant near-capacity load and clients complete steadily. Decorrelated jitter performs slightly better at very high N (more clients), with a flatter load profile.
The numerical takeaway. Brooker’s article shows graphs of retry rate over time. The no-backoff line is exponential growth (cascade). The no-jitter exponential is sharp periodic spikes. The full-jitter line is smooth. Visually, the difference is dramatic: jitter is the difference between a converging system and a thrashing one. The math underneath: synchronization concentrates load in narrow time windows; randomization spreads load across the window; load that exceeds capacity instantly causes failure, while load that would exceed capacity if synchronized but spread across a window does not.
When the math diverges from intuition. A common intuition is that jitter “doesn’t matter much” because the average delay is the same. The intuition is wrong: the peak delay is what matters, not the average. A retry distribution with average 100ms and peak 100ms (deterministic) produces a 1000-client spike at exactly T+100ms; a retry distribution with average 50ms and peak 100ms (full jitter, uniform 0-100ms) produces clients spread across [T+0, T+100ms] with peak rate ~10× lower. The peak-vs-average distinction is what jitter exploits.
The implication for production tuning: when measuring retry behavior, peak rates and synchronization indicators (request bunching) are more important than average rates. A retry mechanism with low average load can still cause synchronized spikes that overwhelm downstream capacity at the peaks.
12.2 Idempotency Key Implementation — The Subtle Bits
The §3 Principle 1 statement that retries require idempotency is well-known; the implementation details are where production systems fail in subtle ways.
Key derivation. The idempotency key must be unique per logical request, identical across retries of that request, and not collide with unrelated requests. Common approaches: client-generated UUID per request (Stripe’s pattern; the client decides); server-generated key from a content hash + timestamp (less common, requires server cooperation pre-execution); SHA-256 of the request body (deterministic but doesn’t distinguish identical-content-different-intent requests). Stripe’s UUID pattern is canonical because it’s simple, client-controlled, and never collides.
Storage duration. The idempotency-key cache must persist long enough that all reasonable retries find it. Stripe stores keys for 24 hours; some systems store indefinitely (using the key as a partial primary key). Too-short storage means retries after the storage duration produce duplicates (the cache lost the original; the retry executes again); too-long storage means accumulated cache cost. 24 hours is a reasonable default for most synchronous APIs; longer for async / webhook scenarios.
Atomicity of “check and execute.” The check (“have we processed this key before?”) and the execution (“process the request”) must be atomic, or two concurrent retries can both pass the check and both execute. The standard approach is database-level: insert the key first (with ON CONFLICT DO NOTHING or equivalent); only execute if the insert succeeded; on conflict, return the previous response. Without atomicity, a race condition produces duplicate processing.
Storing the response. After execution, the response must be stored alongside the key, so future retries can return the original response without re-executing. This is the durability of idempotency: the cached response is the source of truth for “what was the original request’s outcome.” Storing only “we did this” (without the response) means retries return success without the actual result data.
Handling failures during execution. If the original request crashes mid-execution (after key insertion, before result storage), the system is in an indeterminate state. The retry’s check finds the key; without a stored response, it cannot return the original result. The choice: re-execute (risking duplication) or fail (with an “indeterminate” error). Stripe’s approach is to allow re-execution within a window; durable workflow engines (Temporal) re-execute deterministically.
Server-side vs client-side idempotency. Stripe’s pattern is server-side: the server holds the cache and de-duplicates. An alternative is client-side: the client tracks “I sent request X; I haven’t seen a response; I’ll retry with key X.” The server is unaware of duplicates. This works only if the operation is naturally idempotent (the server can process duplicates safely); for non-naturally-idempotent operations, server-side de-duplication is required.
The IETF draft for the Idempotency-Key HTTP header standardizes the server-side pattern. Adoption is growing across cloud APIs, payments platforms, and modern internal APIs.
13. See Also
- System Architectures MOC
- SWE Interview Preparation MOC
- Microservices Architecture — context where retries are pervasive
- Service Mesh System Design — sidecars often handle retries transparently
- API Gateway System Design — gateways may retry against upstreams
- Distributed Log System Design — Kafka producers retry with backoff
- Webhook Delivery System Design — long-horizon retries via durable queues
- Circuit Breaker Pattern — composes with retries; outer protection layer
- Bulkhead Pattern — limits concurrency including retry-induced concurrency
- Timeout and Deadline Pattern — per-attempt timeouts trigger retries
- Fallback and Graceful Degradation Pattern — invoked when retries exhaust
- Token Bucket — used in retry budgets to cap retry rate
- Sliding Window Rate Limiter — alternative; complementary to retries
- Idempotency in Distributed Systems — the foundational primitive for safe retries
- Saga Pattern — saga steps and compensations are retried
- Event-Driven Architecture — async retry queues are common in event-driven systems