Resilience Patterns
- Feel the pain of cascade failure before reaching for the cure
- Implement the circuit breaker pattern as the first defense
- Apply bulkhead isolation to stop one bad dependency from sinking the ship
- Design retry with jitter to avoid making outages worse
- Build a timeout hierarchy so every layer has time to recover
- Preserve user intent even when the system cannot execute it immediately
The Story: 11:07 PM, Black Friday
It is 11:07 PM on the biggest shopping night of the year. Your e-commerce platform is having a record Black Friday — 18,000 orders per minute, more than last year’s peak. Marketing is thrilled. The on-call engineer is sipping coffee, watching dashboards stay green. For 43 minutes, everything is perfect. Then, somewhere upstream, a third-party payment processor hiccups. Not a full outage — just a slowdown. Their p99 crawls from 180ms to 8 seconds. Not timeouts, not 500s, just… slow. Your Payment Service calls them synchronously over HTTP. You have 200 worker threads in the Payment Service fleet. Each in-flight request now holds one of those threads for 8 seconds instead of 180ms. Do the math: 200 threads × 8 seconds = 1,600 seconds of blocked capacity every wall-clock second. New requests queue. The queue fills. Threads never free up fast enough to drain it. Meanwhile, Order Service is calling Payment Service synchronously. Every order is now holding an Order Service thread waiting on Payment’s frozen thread. Within 60 seconds, Order Service runs out of threads too. API Gateway is calling Order Service. Same story. Within 90 seconds from the first payment slowdown, your entire fleet is frozen. Users see spinning loaders. The mobile app shows the “something went wrong” screen. Your CEO is calling. Your Slack is a wall of red PagerDuty alerts. Here is the gut-punch: the payment processor never actually went down. They had a 6-minute blip. Your system, by contrast, was down for 47 minutes — long after the underlying issue healed — because once every thread pool in your fleet is saturated, nothing drains on its own. Orders placed at 11:07 PM did not even get a response until 11:54 PM, and most of them got 504s. Revenue lost: roughly 1.4 million dollars. The post-mortem will eventually name this what it is: cascade failure.What would have saved you?
Rewind the tape. Ask yourself four questions. Each one points at a pattern that earns its keep. Question 1: What if Payment Service had caught the problem at the 5th consecutive failure and stopped even trying to call the payment processor? It would have failed fast — instant response, thread released, pool protected. That is a circuit breaker. It watches the failure rate and, when things go bad, flips a switch that short-circuits every call until the downstream recovers. You trade a handful of rejected requests now for the ability to keep serving every other request on the platform. Question 2: What if Order Service had reserved a separate pool of threads for calling Payment Service — say, 50 threads out of its 200 — so that even if Payment went fully catatonic, the other 150 threads could still handle cart updates, order lookups, and anything that did not touch payment? That is the bulkhead. The name comes from ships. The Titanic had compartments in its hull — watertight bulkheads — so that a single breach could flood one section without sinking the vessel. Your thread pools, connection pools, and concurrency limits are your compartments. Partition them per dependency, and one sick downstream can only starve its own compartment. Question 3: What if Payment Service, when it knew the payment processor was down, returned a cached authorization for repeat customers instead of an error? Or queued the payment for later and responded “payment pending”? That is a fallback. A fallback is a plan B for the answer you wanted — a lesser answer that is still useful. Often the fallback is a stale cache, a default value, or a deferred promise. Any of those beat an error page. Question 4: What if every call in the chain had an enforced timeout, with the inner timeouts shorter than the outer ones, so that a blocked call would unwind within a predictable deadline instead of holding the thread indefinitely? That is the timeout hierarchy. Without timeouts, “slow” eventually becomes “forever.” With the wrong timeouts, the outer layer gives up before the inner layer has a chance to fail gracefully and fall back. Four patterns. Each one earns its place by answering one of those four questions. The rest of this chapter is about how to implement them correctly, how to combine them, and — most importantly — how to avoid the failure modes that come from doing them wrong. Because here is the awkward truth: misconfigured resilience patterns can cause outages that are just as bad as having no patterns at all. A circuit breaker with a 1-failure threshold trips on a single hiccup and blocks healthy traffic. A retry policy without jitter turns a 10-second outage into a 45-minute thundering-herd disaster. A timeout that is longer than your caller’s timeout causes both layers to time out and report wrong error messages. We will get all of these right.Why Resilience Matters
In a monolith, a slow database query makes one request slow. In microservices, that same slow query can take down your entire platform. Here is why: when Service A calls Service B, it holds a connection, a thread, and memory while waiting. If B is slow, A’s resources pile up. Soon A runs out of threads too. Then services calling A start piling up. Within seconds, a single weak link cascades into total platform failure. This is not theoretical — it is the most common root cause of major outages at Netflix, Amazon, and every large distributed system. Resilience patterns exist because we cannot prevent downstream failures, but we can prevent them from becoming our failures. The core insight: fail fast, fail isolated, and have a plan B. Without these patterns, one sick service kills the whole fleet. With them, a sick service degrades one feature while the rest of the platform keeps serving users.Circuit Breaker Pattern
Prevent cascade failures by “breaking the circuit” to failing services. Why this pattern exists: Imagine a light switch that trips when there is an electrical fault — the circuit breaker in software does the same thing. When a downstream service starts failing consistently, every retry wastes resources (threads, sockets, memory) and piles latency onto already-struggling infrastructure. Worse, your callers are kept waiting for timeouts that take seconds each. Without a circuit breaker, every slow failure costs you the full timeout duration multiplied by every caller. The circuit breaker short-circuits this: after detecting N failures in a window, it stops even trying and fails instantly. The tradeoff is that you will occasionally reject requests that would have succeeded, but you exchange that minor loss for keeping your whole platform responsive. What it prevents: Cascade failures where one sick service exhausts its callers’ thread/connection pools, which then exhaust their callers’ resources. Within 30 seconds, an entire microservice fleet can be frozen because one obscure downstream dependency went sideways.State Machine
The state machine is the heart of the pattern. Think of it as a three-position toggle: normal (CLOSED), tripped (OPEN), and tentatively-recovering (HALF-OPEN). The magic is in the transitions. CLOSED is the happy path: requests flow, failures are counted. When failures exceed the threshold, you flip to OPEN: every call fails instantly without even trying the downstream. After a reset timeout, you move to HALF-OPEN and let a few trial requests through. If they succeed, great — back to CLOSED. If they fail, back to OPEN for another cooldown. This cautious probing is what allows the system to heal without slamming a recovering service with full traffic the instant it comes back.Implementation from Scratch
Building a circuit breaker from scratch is useful because the production libraries (opossum, resilience4j, pybreaker) have opinionated defaults that may not fit your workload. The key design choices: what counts as a “failure” (HTTP 5xx? timeouts? specific errors?), how you count failures (consecutive vs. rolling window), and how you handle the transition to HALF-OPEN (one test request, or a percentage of traffic). Without a circuit breaker, every caller waits the full timeout for every failing request — 1000 concurrent users times 5 seconds per timeout equals 5000 wasted seconds across your fleet. The tradeoff is operational complexity: a misconfigured breaker (too sensitive) will trigger on benign spikes; too lenient and it will fail to protect you. The Python version below usespybreaker, a battle-tested library with Redis-backed state so multiple service instances share the same breaker state.
- Node.js
- Python
Using with Service Clients
A circuit breaker only adds value when wired into the code path that actually makes downstream calls. The pattern below wraps a service client so every call goes through the breaker. Critically, we provide a fallback function for each operation: when the breaker is open (or the call fails), the fallback runs instead of throwing. For writes (likeprocessPayment), the fallback queues the work for later. For reads (like getPaymentStatus), the fallback returns cached data with a _fromCache flag so callers know it may be stale. Without this pattern, every place that calls the Payment Service would need to duplicate the try/catch/fallback logic — and developers would forget half the time. Centralizing resilience in the client means one implementation, consistently applied.
- Node.js
- Python
Circuit Breaker Caveats and Interview Deep-Dive
Your circuit breaker is open 40 percent of the time. What does that tell you and what do you do?
Your circuit breaker is open 40 percent of the time. What does that tell you and what do you do?
- This is not a configuration issue — it is a real problem. Forty percent open means the downstream is genuinely failing a meaningful fraction of the time. First step: verify. Pull downstream error-rate metrics. If they confirm 30-plus percent actual failure, the breaker is doing its job.
- Check whether the threshold is too aggressive. If the downstream’s true error rate is 5 percent but the breaker trips on 3 consecutive failures, you are tripping on random clustering. Switch to percentage-based thresholds over a time window.
- Check the classification of “failure”. Are 4xx errors being counted? Are timeouts for healthy-but-slow responses being counted? The “failures” may not be what you think.
- Look at the blast radius. Is the breaker scoped per-instance or per-endpoint? One flaky instance in a fleet of 20 should not trip a fleet-wide breaker. Consider per-host breakers inside a service mesh (Envoy outlier detection).
- Talk to the downstream team. A 30-plus percent error rate on a production service is an incident. The breaker is masking the symptom; the root cause needs ownership. The circuit breaker is a shock absorber, not a fix.
- Improve the fallback. If the downstream is genuinely unreliable and cannot be fixed short-term, invest in better fallbacks: richer cache, queued writes, alternate provider. The breaker protects you; the fallback preserves the user experience.
- “How do you tell a breaker tripping on real failures apart from one tripping on misconfiguration?” Correlate with downstream success-rate metrics. If downstream reports 99.9 percent availability but your breaker trips 40 percent of the time, the mismatch says your threshold is wrong or you are counting wrong things. If downstream confirms real outages, your breaker is telling the truth.
- “Should the breaker ever trip permanently?” No. Always have an automatic reset window (HALF-OPEN probe). A permanently-open breaker means a human must intervene, which does not scale and does not handle the common case where the downstream recovers. The safety property you want is “probe and close if healthy, re-open if still sick.”
- “What if the breaker is open across all your callers and now the downstream has zero traffic to probe against?” This is the “self-fulfilling prophecy” problem. HALF-OPEN lets through a small probe load — enough to detect recovery. For a service with no natural baseline traffic, add synthetic health checks the breaker can use as probes. AWS App Mesh and Istio both support this.
- “Disable the breaker or raise the threshold way up.” Treats the symptom, not the cause. If the breaker is open 40 percent, something real is broken. Disabling protection does not fix it; it just makes the user experience worse.
- “Retry more aggressively to mask the failures.” Piling retries on top of an already-failing downstream creates a retry storm that makes things worse. Never solve a “high error rate” problem with “try harder.” Fix the root cause.
- Michael Nygard, Release It! — the canonical text on stability patterns; chapters on circuit breaker and bulkhead.
- Netflix Tech Blog, “Making the Netflix API More Resilient” (Hystrix origins).
- Envoy outlier detection documentation — per-host breaker semantics.
Walk me through designing a circuit breaker for a downstream that has 99.95 percent availability but 5 percent of requests are naturally slow (latency outliers).
Walk me through designing a circuit breaker for a downstream that has 99.95 percent availability but 5 percent of requests are naturally slow (latency outliers).
- Separate “slow” from “failed”. Slow is latency; failed is error. Do not conflate them. If a request succeeds in 3 seconds, it succeeded. You handle it with a timeout, not a breaker.
- Set the timeout above the p99, below user patience. If p99 is 2 seconds, timeout at 3 seconds. Below p99 and you will timeout healthy-but-slow requests. Above user patience (say, 10 seconds) and you waste resources.
- Use percentage-based failure thresholds. For a 99.95 percent service, a 2-of-3 threshold will trip on natural variance. Switch to “trip if error rate exceeds 20 percent over 20 requests in a 30-second window.”
- Distinguish timeouts from connect errors. Connect errors usually mean the downstream is down or unreachable. Timeouts mean it responded slowly. Both should count toward the breaker, but consider weighting them differently; connect errors are stronger signals.
- Monitor the breaker itself. Dashboards for trip rate, HALF-OPEN probe count, and time-in-OPEN. If the breaker is tripping more than a few times a week, either the downstream or the config needs work.
- “How do you tune the reset timeout?” Start at 30 seconds. Too short and you probe a still-sick downstream too often. Too long and you keep traffic off an already-recovered downstream. Watch the recovery pattern: if the downstream typically recovers in 60 seconds, 30-second reset is fine (you probe at 30, possibly fail, reset, probe at 60, succeed). If recovery takes 5 minutes, longer reset windows make sense.
- “What about circuit breakers that use response-time percentiles to trip?” Advanced pattern: trip when p99 latency exceeds some multiple of baseline. Catches “brown-outs” where the service is up but struggling. Hard to tune correctly. Starts making sense when you have a large, well-instrumented fleet.
- “How does a service mesh change the picture?” Mesh (Envoy) moves breakers out of app code into the sidecar. Benefits: consistent behavior across languages, centralized config, global view. Costs: one more moving piece, extra latency (sub-millisecond), sidecar upgrade pain. Good default for orgs already committed to a mesh; overkill for small teams.
- “Retry on every failure to smooth over the 5 percent outliers.” Retries a slow request — which then also runs slowly — doubling the latency budget. Worse, if the downstream is overloaded, retries add load that makes it more overloaded.
- “Set the timeout very high to avoid false positives.” High timeouts hold resources during real outages, creating cascade failure. Timeout should bound the worst acceptable latency, not the best-case success window.
- Hystrix wiki, “How Hystrix Measures Success / Failure.”
- Google SRE Book, chapter on handling overload.
- Brendan Gregg, “Latency Heatmaps” — for understanding latency distributions before tuning timeouts.
Retry Patterns
Retry Strategies
Why retries exist: Most failures in distributed systems are transient: a packet was dropped, a leader election was in progress, a GC pause happened, a connection was torn down by a load balancer. If you retry a second or two later, it just works. Without retries, every transient blip becomes a user-visible error. With naive retries, you create new problems: a “thundering herd” where thousands of clients hammer a recovering service simultaneously, pushing it back into failure. The solution is exponential backoff with jitter: wait longer between each attempt, and add randomness so callers spread out their retries instead of synchronizing. Key tradeoff: Retries amplify load. If a request normally takes 1 second and you allow 3 retries, a failing downstream can cost you 7+ seconds of user-facing latency and 4x the load on the struggling service. Always pair retries with a circuit breaker so that a truly broken service does not get retried into oblivion.Implementation
The retry policy below captures several important decisions beyond “just loop a few times.” First, what is retryable: network errors and specific HTTP status codes (408, 429, 500, 502, 503, 504) are retryable; 4xx errors like 400 or 401 are not — retrying them just wastes effort since the request is malformed. Second, how to delay: exponential backoff with a jitter factor prevents the thundering herd. Third, respecting server hints: if the server returnsRetry-After, honor it rather than computing your own delay — the server knows when it will be ready. In Python we use the tenacity library, which is the de facto standard. It provides decorators for retry logic, hooks to log before each retry (via before_sleep), and composable stop/wait conditions.
- Node.js
- Python
Combining Circuit Breaker and Retry
Why combine them: Circuit breakers and retries handle different failure modes. Retries handle transient failures (“the packet dropped, try again”). Circuit breakers handle systemic failures (“this service is broken, stop trying”). Alone, retries can hammer a dying service into the ground; alone, a circuit breaker will fail requests that would have succeeded on a quick retry. Together, they cover both cases: the retry handles the blip, and if failures persist, the circuit breaker trips to stop the bleeding. The order matters: the circuit breaker must wrap the retry loop, not the other way around. Otherwise each retry opens and closes the circuit individually, defeating the purpose.- Node.js
- Python
Retry Caveats and Interview Deep-Dive
After an outage, your downstream comes back up and immediately crashes again. Why, and how do you prevent it?
After an outage, your downstream comes back up and immediately crashes again. Why, and how do you prevent it?
- Diagnose first. The downstream survived the original failure cause but is now being killed by load. Check the load at the moment of crash — if it is 5-10x normal, that is a retry storm.
- Identify the retry herd. During the outage, N callers queued retries. At recovery, all N retries fire simultaneously. Downstream capacity is designed for steady state, not for 10 seconds of 10x load.
- Apply jitter. Add random delay to every retry so the herd spreads across time.
delay = base * (1 + random(0, 1))is the simplest version; AWS’s “full jitter” algorithm (delay = random(0, base * 2^attempt)) is even better for very-high-fan-out scenarios. - Apply backoff caps. Maximum retry delay of, say, 30 seconds. Without a cap, exponential backoff sends some retries minutes or hours later, creating a long tail of load surprise.
- Gradual recovery with circuit breakers. When a circuit transitions from OPEN to HALF-OPEN, only allow a small percentage of traffic through. If probes succeed, gradually increase. If they fail, reopen. Avoids slamming a recovering service.
- Protect the downstream with rate limiting. The downstream itself should reject traffic above its capacity rather than trying to serve it and dying. Token bucket at the ingress is a cheap insurance policy.
-
“What’s the difference between equal jitter, full jitter, and decorrelated jitter?” Equal jitter adds a random fraction up to the base delay (
delay = base/2 + random(0, base/2)). Full jitter randomizes the entire window (delay = random(0, base * 2^attempt)). Decorrelated jitter uses the previous delay as a seed for the next (delay = min(cap, random(base, prev * 3))). Full jitter is simpler and usually best for high-fan-out systems; decorrelated is better when you want smoother retry distributions. - “When does retry budget kick in vs circuit breaker?” Retry budget limits how much retrying the client fleet can collectively do. Circuit breaker limits whether any retry happens at all when the target is clearly down. Together: budget is “slow down retries when load is high”; breaker is “stop entirely when the target is dead.” Neither replaces the other.
- “How do you roll out jitter to a production system that does not have it?” Start by adding jitter on the outermost retry layer only. Measure the effect on downstream load spikes during rollouts and deployments — the typical smoothing effect is visible within a week. Then push jitter into inner layers one at a time, verifying each does not create new coordination problems.
- “Add more capacity to the downstream so it can handle the spike.” Treats the symptom. A 10x retry spike on recovery means you need 10x capacity for 1 percent of the time. Jitter costs nothing.
- “Disable retries to prevent storms.” Goes too far. Retries are valuable for transient failures. Disabling loses that value. Jitter and budgets preserve the value while fixing the storm.
- Amazon Builders’ Library, “Timeouts, retries, and backoff with jitter.”
- Google SRE Workbook, Chapter 9, “Addressing Cascading Failures.”
- Marc Brooker’s blog posts on retry and backoff mathematics.
Bulkhead Pattern
Isolate failures to prevent them from affecting other parts of the system.The Story: Why the Titanic Really Sank
Everyone remembers the iceberg. Almost nobody remembers the real reason the Titanic went under. The Titanic’s hull was divided into 16 watertight compartments. The ship was designed to stay afloat with up to 4 of them flooded. The iceberg tore a 300-foot gash that breached 5 or 6 compartments — bad, but here is the uncomfortable truth that emerged from the inquiry: the bulkheads between compartments only extended up to E Deck, roughly 10 feet above the waterline. They did not reach the ceiling of the hull. So when the forward compartments flooded, the bow dipped down, water spilled over the top of the bulkheads into adjacent compartments, which then flooded, which dipped the bow further, which caused more spillover. Each compartment that flooded made the next one easier to flood. The bulkheads were real. They just were not high enough to actually isolate. Your thread pools and connection pools are the bulkheads of your service. If Payment Service uses the same 200-thread pool for calls to its internal database, calls to the fraud-detection service, and calls to a flaky 3rd-party tax API, then when that tax API gets slow, one flaky dependency can consume the entire pool. Database queries queue. Fraud checks queue. The pool fills with tax calls waiting for an 8-second p99. The other dependencies that were perfectly healthy get starved of threads. One sick dependency sinks the whole service — not because the dependency was critical, but because all your compartments were connected at the top. The bulkhead pattern is the fix: give each downstream dependency its own isolated thread pool (or semaphore, or connection pool). Tax service gets 30 threads. Fraud service gets 50. Database gets 100. Something else gets the rest. When the tax API goes sideways, only the tax pool saturates — Payment Service keeps answering database and fraud calls without a hiccup. The flaky dependency is isolated to its own compartment, and the breach cannot spread.Implementation
A bulkhead has two knobs:maxConcurrent (how many in-flight calls allowed at once) and maxQueue (how many can wait). When activeCount < maxConcurrent, calls execute immediately. When it’s saturated, calls queue up to maxQueue. Beyond that, we reject fast — failing quickly is far better than queuing indefinitely and surprising callers with 30-second latency. The queue also has a timeout: if you’ve been waiting that long, the caller has likely given up, so don’t bother running the work. In Python’s single-threaded async world, asyncio.Semaphore is the idiomatic implementation — it does exactly this counting semantics natively.
- Node.js
- Python
Semaphore-Based Bulkhead
Why a semaphore approach: The queue-based bulkhead above gives you full control (metrics, queue timeouts, custom rejection). But often you just want “no more than N of these at a time.” A semaphore is the textbook primitive for that. In Node.js, since there is no built-in async semaphore, you roll your own with a waiters array. In Python,asyncio.Semaphore is already part of the standard library — no dependency needed. The BulkheadManager pattern lets you create and look up per-service bulkheads by name, which is exactly what you need when one service has many downstream dependencies each needing isolation.
- Node.js
- Python
Per-Dependency Bulkheads: The Real Win
Most engineers discover bulkheads as “limit concurrency.” The deeper insight is one bulkhead per downstream dependency. Below is the pattern applied to the Titanic scenario: Payment Service has three downstreams, each with its own pool size chosen to match that dependency’s expected throughput and latency profile.- Node.js
- Python
Bulkhead Caveats and Interview Deep-Dive
Your bulkhead for the fraud-detection service has max=30. During a traffic spike, it saturates and rejects 8 percent of requests. What do you do?
Your bulkhead for the fraud-detection service has max=30. During a traffic spike, it saturates and rejects 8 percent of requests. What do you do?
- Verify the bulkhead is the bottleneck, not the downstream. Pull metrics: if fraud-detection is responding in 150 ms but your bulkhead is full, the bulkhead is undersized for current load. If fraud-detection p99 is 2 seconds, the real issue is downstream latency; more concurrency will not help, it will just create a bigger backlog.
- Compute the right size. Little’s Law:
concurrency = throughput * latency. If you need 500 RPS at 200 ms per call, required concurrency = 100. Current bulkhead of 30 is too small by 3x. - Resize with caution. Raise the bulkhead gradually (e.g., 30 -> 50 -> 80) and observe downstream latency. Doubling concurrency can sometimes cause the downstream’s latency to spike, negating the gains.
- Check the downstream’s capacity. If fraud-detection can only handle 400 RPS total across all callers, raising your bulkhead beyond that fraction pushes the limit onto them. Coordinate with that team.
- Add queuing if bursts are the issue. Steady load needs concurrency; bursts may tolerate queuing.
maxQueue = maxConcurrent * 3with a 500 ms queue timeout lets you absorb short bursts without rejecting. - If the dependency is truly not critical, consider non-blocking fallback. If fraud-detection being saturated is not an outage (you can approve with a default score), return the fallback faster rather than queuing.
- “How do you handle a dependency that has unpredictable latency? Sometimes 100 ms, sometimes 3 seconds.” Bulkhead size based on p99 latency, not average. If latency is highly variable, add a tight timeout so slow requests fail fast and free the slot. Bulkhead of 50 with 1-second timeout can handle much more throughput than bulkhead of 50 with 10-second timeout.
- “Is a bulkhead sufficient, or do you also need a circuit breaker?” Both, for different jobs. Bulkhead caps concurrent calls (prevents resource exhaustion). Circuit breaker skips calls entirely when downstream is known-bad (prevents wasted attempts). You want both: bulkhead contains the damage, breaker eliminates it when the downstream is down.
- “What happens during a pod restart? Does the bulkhead reset?” In-memory bulkhead state is lost on restart, so yes. This is usually fine (a restarted pod is fresh and can accept traffic), but it means a rolling restart during a degraded downstream can cause waves of “first N requests succeed while state is empty.” Observe for this; use stickier state (Redis-backed) if it is a problem.
- “Remove the bulkhead; it is blocking legitimate traffic.” Misses the point. The bulkhead is doing its job — exposing that steady-state load exceeds the configured capacity. Remove it and the next outage will cascade.
- “Set bulkhead to unlimited to prevent rejections.” Equivalent to removing the bulkhead. Now one slow dependency can consume all resources, which is exactly what bulkheads prevent.
- Michael Nygard, Release It!, chapter on Bulkheads.
- Netflix Hystrix documentation, “How it Works,” section on thread pools.
- Envoy documentation on circuit breakers (which in Envoy terminology includes concurrency limits, i.e., bulkheads).
Retry with Exponential Backoff and Jitter
The Story: The 2:01 PM Thundering Herd
Picture this. It is 2:00 PM on a Tuesday. Your Search Service crashes — a single bad deploy, caught within 60 seconds. At 2:01 PM exactly, the deploy is rolled back. Search is healthy again. Total downtime: one minute. But now watch what happens to the 10,000 mobile clients that were using Search during those 60 seconds. Every one of them hit an error. Every one of them has retry logic: “on failure, wait 60 seconds and try again.” Their retry timers are triggered by the failure time, which clustered tightly around 2:00 PM give or take a few seconds. At 2:01 PM, all 10,000 clients’ retry timers expire within the same second. All 10,000 clients fire their retry at the same instant. Your Search Service, which just came back up, now receives 10,000 requests in one second when it normally handles 200. The Search Service falls over again. Now those 10,000 clients fail a second time. Their second retry is scheduled for 2:02 PM — again, 60 seconds after failure, again clustered within a second of each other. At 2:02, another 10,000 requests arrive simultaneously. Search dies again. You are now in a resonance loop where your own retry logic is killing the service every 60 seconds. The iceberg damaged the hull for 60 seconds, but your retry pattern is keeping the water pouring in. This is the thundering herd problem. Naive retries synchronize clients in time, so every retry wave is more concentrated than the traffic that caused the original failure. Your retry logic, intended to heal outages, causes them.Why Exponential Backoff Alone Is Not Enough
The first fix that comes to mind is exponential backoff: wait 1s, then 2s, then 4s, then 8s. This helps — retry attempts spread out over time instead of piling on immediately. But it does not fully solve the herd problem, because every client is still using the same schedule. At 2:01 all clients fire their first retry simultaneously. At 2:03 all clients fire their second retry simultaneously. The waves are further apart, but each wave is still synchronized. A big service with 10,000 clients still gets a 10,000-request spike every wave. The actual fix is jitter: add randomness to each delay so that clients desynchronize. Instead of every client waiting exactly 2s, each client waits somewhere between 0s and 2s (full jitter), or 1s + a random fraction of 2s (equal jitter). Over a small number of retries, the retry load gets smeared across a window instead of landing as a spike. 10,000 retries spread over a 2-second window is 5,000 requests per second — often survivable where an instantaneous 10,000-request spike is not.The Formula
The AWS Architecture Blog recommends full jitter, which has held up best in practice:base= initial delay (e.g., 1 second)cap= maximum delay ceiling (e.g., 30 seconds) so clients do not wait foreverattempt= 0, 1, 2, … retry countrandom(0, X)= uniform random between 0 and X
Implementation
Below, every retry picks its own jittered delay. Node.js usesMath.random(). Python uses random.uniform(). Both respect a max_delay cap so no single retry waits absurdly long.
- Node.js
- Python
Intent Parity Under Failure
The Story: “Add to Cart” Does Not Mean “Maybe Add to Cart”
You are shopping on a Tuesday evening. You have 6 items in your cart. You tap “Add to Cart” on a seventh. Somewhere in the backend, the Cart Service is having a rough minute — GC pause, database blip, it does not matter. The request to add the item times out. Here is what bad apps do: they show you “Oops, something went wrong” in a red toast. Your item is not added. Your intent — “I want this in my cart” — was received by the system and then silently dropped. You tap again. Same error. You give up. The shop loses the sale. Worse, you suspect the whole app of being flaky, and you remember that feeling the next time you open it. Here is what good apps do: they show you “Added — we’ll sync shortly.” The item appears in your cart in the UI. Behind the scenes, the intent was captured durably (in a local queue, in a durable event log, in an outbox table) and will be replayed to the Cart Service once it recovers. From your perspective, the shop never broke. From the system’s perspective, the Cart Service was briefly degraded but no intent was lost. This is intent parity under failure: the user’s original request — their intent — is preserved and eventually fulfilled, even when the system cannot execute it immediately. Not “we tried and failed.” Not “please try again later.” The intent is captured, acknowledged, and durably stored, then fulfilled when conditions allow. Intent parity is not just UX polish — it is an architectural commitment. It says: we will honor what the user asked for, and we will not hide our degradations by silently dropping their requests.The Three-Step Pattern
Every intent-parity implementation has the same shape:- Capture intent durably — at the edge (client-side IndexedDB, service-local outbox table, or event log), write a record of what the user wanted. This write must be durable and must happen before you try the real operation. If the capture fails, only then do you tell the user no.
- Return an optimistic response — acknowledge to the user that their intent is accepted. Optionally surface the pending state (“Added — syncing”). Do not wait for the downstream to succeed before responding.
- Process the intent when service recovers — a background worker drains the queue, calling the real service. Failed attempts are retried with backoff. Permanently failed intents are moved to a dead-letter queue for human review.
Implementation
Below, theCartService captures every add-to-cart intent into a durable outbox first, then attempts the synchronous call. If the sync call fails or the Cart Service is circuit-open, the user gets an optimistic PENDING response — they never see an error. A separate worker drains the outbox when the Cart Service recovers.
- Node.js
- Python
Timeout Patterns
Why timeouts exist: Every network call must have a deadline, period. Without timeouts, a stuck downstream holds your thread or coroutine hostage forever. TCP itself may eventually give up (minutes), but your users gave up 30 seconds ago. Timeouts are the foundation that circuit breakers and retries build on: without an enforced upper bound on call duration, neither pattern can detect failure. The hard question is what timeout to use. Too short and you’ll timeout healthy-but-slow requests; too long and you’ll hold resources while users have already refreshed the page. The right number is usually 2-3x your p99 latency for that dependency, with adaptive adjustments if traffic patterns shift. Cascading timeouts are a related discipline: each layer of your call stack must have a shorter timeout than the layer above it. If the API gateway times out at 5s and calls Order Service with a 5s timeout, when Order Service finally returns an error, the gateway has already moved on. The gateway now returns a less useful timeout error instead of Order’s real error. Shortening inner timeouts (4s, 3s, 2s going inward) leaves each layer time to handle failures gracefully.- Node.js
- Python
Timeout Caveats and Interview Deep-Dive
Your API gateway times out at 3s, Order Service times out at 5s. What goes wrong, and how do you fix it?
Your API gateway times out at 3s, Order Service times out at 5s. What goes wrong, and how do you fix it?
- Name the bug. Gateway abandons the request after 3 seconds and returns a generic 504. Order Service still has 2 seconds of work in flight, consuming resources. The caller sees no information about which downstream was slow.
- Apply cascading timeouts. Inner must be shorter than outer. Gateway 3s -> Order Service 2.5s -> any downstream of Order 2s. This gives each layer time to handle failure and return a meaningful response.
- Propagate deadlines. Even better: the gateway forwards the absolute deadline to Order Service (via
grpc-timeoutheader orX-Request-Deadline). Order Service computes remaining budget = deadline - now, and uses that as its own timeout. This prevents any downstream from waiting past the original deadline. - Add deadline awareness in application code. Every cross-service call takes a
contextordeadlineparameter. The HTTP client respects it. Code that ignores deadlines is a bug. - Monitor for “wasted work” after the caller gave up. Metrics like “request completed after upstream timeout” help identify where deadline propagation is broken.
context has first-class deadline support.Senior Follow-up Questions:- “What about retries within the timeout budget?” Retries eat into the total deadline. If your outer deadline is 3 seconds and your per-attempt timeout is 1 second, you can do at most 3 attempts before the deadline. Retry logic must check remaining time before each attempt — don’t retry if less than one attempt’s worth of budget remains.
- “How do you set the right timeout when you don’t know the downstream’s p99?” Start conservative: 2x your SLO target for the overall request. Measure real latency. Tighten over time based on data. Do not launch with a 30-second default “just in case” — that creates cascade failure conditions.
- “What if a service intentionally takes longer than the deadline (e.g., a long-running report)?” Long-running operations should not run synchronously inside a request/response cycle. Change the contract: the request kicks off the job and returns a job ID; clients poll for completion or subscribe to a webhook. Synchronous “wait 30 seconds” APIs are a design smell that the entire call chain will have to work around.
- “Raise the gateway timeout to 10 seconds so Order Service has time to finish.” Creates worse problems: users wait longer; gateway threads / connection pool fill up; upstream callers time out on the gateway. Raising timeouts is almost never the right fix.
- “Lower Order Service’s timeout to 3 seconds (same as gateway).” Still broken. If Order Service takes its full 3 seconds, the gateway has no time to process the response or return to the client. Inner must be strictly shorter than outer.
- Amazon Builders’ Library, “Timeouts, retries, and backoff with jitter.”
- Google gRPC documentation, “Deadlines.”
- Google SRE Book, chapter on handling overload (deadline propagation).
Complete Resilience Stack
Why compose these patterns: Each pattern alone is useful; together they form a defense in depth. Think of the layers as concentric shields: the bulkhead limits the blast radius (“only 10 in-flight payment calls at a time”), the circuit breaker provides fast failure (“if payments are broken, fail instantly”), the retry handles transient noise (“retry the occasional blip”), and the cache provides a read-time fallback (“if everything fails, return stale data”). Without this full stack, one layer can undo another: retries hammer a failing service without a breaker; a breaker trips too eagerly without retries to absorb blips; a service starves other callers without a bulkhead. The key implementation detail is the order of wrapping:- Bulkhead (outermost) — enforce concurrency before you even start the call
- Circuit breaker — fail fast if we know the downstream is sick
- Retry — handle transient failures within a successful bulkhead+breaker path
- The actual operation (innermost)
- Node.js
- Python
Health Checks
Why health checks matter: Orchestrators (Kubernetes, ECS, Nomad) need to know whether your container is healthy so they can route traffic correctly. Without health checks, k8s will happily send requests to a pod whose database connection died three minutes ago, surfacing errors to users that should have been contained by removing the pod from the load balancer. Two distinct probes exist: liveness asks “is the process alive?” — if it fails, k8s kills and restarts the pod. Readiness asks “can this pod serve traffic?” — if it fails, k8s leaves the pod running but stops sending requests. They have different semantics on purpose: a pod warming its cache is alive but not ready; a deadlocked pod may be ready by the load balancer’s view but not actually alive. Conflating them leads to either restart loops (liveness too strict) or serving broken pods (readiness too lenient). TheHealthChecker pattern below registers checks with a critical flag. A non-critical check failing (say, a nice-to-have recommendation service) degrades the overall status but does not mark the pod unhealthy. Critical checks (database, primary cache) being down means the pod is genuinely unable to serve traffic.
- Node.js
- Python
Interview Questions
Q1: Explain the Circuit Breaker pattern and its states
Q1: Explain the Circuit Breaker pattern and its states
- CLOSED: Normal operation, requests pass through
- OPEN: Failure threshold reached, requests fail immediately
- HALF-OPEN: After reset timeout, allows limited requests to test recovery
- Failure threshold (e.g., 50% in 10 seconds)
- Reset timeout (e.g., 30 seconds)
- Success threshold for recovery (e.g., 3 successful calls)
Q2: Why use exponential backoff with jitter?
Q2: Why use exponential backoff with jitter?
- Increases wait time between retries (1s, 2s, 4s, 8s…)
- Reduces load on recovering service
- Allows more time for transient issues to resolve
- Adds randomness to delay (e.g., 2s + 0-500ms)
- Prevents “thundering herd” where all clients retry simultaneously
- Spreads retry load evenly over time
Q3: What is the Bulkhead pattern?
Q3: What is the Bulkhead pattern?
- Separate thread pools per dependency
- Limit concurrent calls per service
- Queue excess requests with timeout
- Payment service: 20 concurrent max
- Inventory service: 50 concurrent max
- If payment is slow, only payment pool is affected
- Failure isolation
- Prevents resource exhaustion
- Graceful degradation
Q4: How do you design cascading timeouts?
Q4: How do you design cascading timeouts?
- Outer service needs time to handle timeout errors
- Prevents double timeout (inner times out, outer times out)
- Enables proper error responses at each layer
Summary
Key Takeaways
- Circuit Breaker prevents cascade failures
- Retry with exponential backoff + jitter
- Bulkhead isolates failure domains
- Cascading timeouts for proper error handling
- Always have fallback strategies
Next Steps
Interview Deep-Dive
'Your circuit breaker for the Payment Service keeps flipping between open and closed every few minutes. Users are complaining about intermittent checkout failures. What is happening and how do you fix it?'
'Your circuit breaker for the Payment Service keeps flipping between open and closed every few minutes. Users are complaining about intermittent checkout failures. What is happening and how do you fix it?'
'Explain the Bulkhead pattern and give me a real scenario where not having it caused an outage.'
'Explain the Bulkhead pattern and give me a real scenario where not having it caused an outage.'
'What is the difference between a timeout, a deadline, and a retry budget, and how do they work together in a microservices call chain?'
'What is the difference between a timeout, a deadline, and a retry budget, and how do they work together in a microservices call chain?'
X-Request-Deadline header containing the absolute timestamp. Each service reads it, calculates remaining time, and uses that as the maximum for its downstream calls. For Kafka, the deadline goes into the message headers. But the semantics change: since Kafka consumers process asynchronously, the deadline becomes “if this message is older than the deadline when I consume it, skip it rather than processing stale work.” This prevents a backed-up consumer from processing requests that the user has already given up on.