Lesson 38 · Cascading failure, feedback loops, shedding

One Slow Dependency

A backend gets slower. Not down — slower. Within minutes every caller is out of threads, half the fleet is marked unhealthy and removed, and the instances still standing are carrying more traffic than the whole fleet carried an hour ago. Nobody deployed anything.

The win in this lesson: you will be able to name the feedback loop out loud during an incident — which quantity is rising, and what that rise feeds — and you will know why just restart it is, more often than not, the thing keeping the outage alive.

1. The definition does all the work

"A cascading failure is a failure that grows over time as a result of positive feedback."

Addressing Cascading Failures, Google SRE Book ch. 22 · chapter opening

Three words carry it: positive feedback. Not "a big outage", not "a lot of errors" — a loop in which the system's response to failure produces more failure. The book's own first example is the whole shape in one sentence:

"a single replica for a service can fail due to overload, increasing load on remaining replicas and increasing their probability of failing, causing a domino effect that takes down all the replicas for a service."

Addressing Cascading Failures, ch. 22 · chapter opening
The diagnostic question

In an incident, ask: is anything the system is doing right now making the problem bigger? Retrying, removing instances, restarting tasks, rebalancing load — these are all reasonable responses to failure, and each of them is also a way to feed the loop. If the answer is yes, you are not debugging a fault. You are trying to interrupt a circuit.

2. The loop, drawn

The trigger here is deliberately boring: one dependency's latency goes from 100 ms to 4 s. It never returns an error. It never goes down. Watch what the fleet does to itself.

load balancer caller A caller B backend steady state — 100 ms per call, 10 of 100 threads busy request query reply in 100 ms · thread released the backend slows: 100 ms becomes 4 s. It returns no errors. Nothing is deployed. request · arriving at exactly the same rate as before query · 4 s, and the thread waits for all of it 100 of 100 threads held queue fills · RAM per in-flight request GET /healthz no thread free to serve it → the check times out A marked unhealthy removed from the serving pool point of no return — A's share of the traffic now lands on the survivors the backend did not get slower. The fleet got smaller. request · B now receives 2x the rate, at the same 4 s per query B fails its health check next. Every removal is correct in isolation, and shortens the life of everything left.
The loop has four turns: slow backend → threads held in the caller → health check unanswered → instance removed → more load per surviving instance → threads held faster. The turn that surprises people is the fourth. Removing a sick instance is the correct, documented behaviour of every load balancer you have ever used, and it is also the accelerant.

The pivot is that a health check is a request. It needs a thread, a scheduler slot, and a free file descriptor like everything else, so it is starved by exactly the conditions it exists to detect:

"When a thread can’t make progress because it’s waiting for a lock, health checks may fail if the health check endpoint can’t be served in time."

Addressing Cascading Failures, ch. 22 · §"Resource Exhaustion" → "CPU"

"Running out of file descriptors can lead to the inability to initialize network connections, which in turn can cause health checks to fail."

Addressing Cascading Failures, ch. 22 · §"Resource Exhaustion" → "File descriptors"

3. Where the capacity actually goes

Overload does not arrive as one clean resource limit. It arrives as a chain, where each exhausted resource consumes the next:

ResourceWhat exhausts itWhat it exhausts next
ThreadsEach in-flight request holds one for the dependency's full latencyHealth checks, then the whole instance
MemoryMore concurrent requests, each holding request, response and RPC objectsGC time, which eats CPU, which slows requests further
CPUSlower requests mean more of them in flight at onceEvery other resource, including cache hit rate
Queue slotsArrivals outpacing completions, even brieflyLatency and memory — a full queue is pure added delay
File descriptorsConnections that are open but idle, waiting on a replyThe ability to accept any new connection at all

"If there is insufficient capacity to handle all the requests at steady state, the server will saturate its queues."

Addressing Cascading Failures, ch. 22 · §"Resource Exhaustion" → "CPU"

A queue looks like patience and behaves like latency. The book's arithmetic is worth memorising because it is so unflattering:

"For example, if the queue size is 10x the number of threads, the time to handle the request on a thread is 100 milliseconds. If the queue is full, then a request will take 1.1 seconds to handle, most of which time is spent on the queue."

Addressing Cascading Failures, ch. 22 · §"Queue Management"

The deadline trap: 5% of requests, 80% of errors

The most counterintuitive number in the chapter. A frontend of 10 servers, 100 threads each — 1,000 threads of capacity, normally 100 in use. Then "Suppose an event causes 5% of the requests to never complete."

normal          1,000 QPS x 0.1 s        =   100 threads busy of 1,000
the 95% good      950 QPS x 0.1 s        =    95 threads
the 5% stuck       50 QPS x 100 s        = 5,000 threads  ← the deadline, not the failure
available                                  1,000 threads
served                1,000 / (5,000 + 95) = 19.6%

"the frontend will only be able to handle 19.6% of the requests (1,000 threads available / (5,000 + 95) threads’ worth of work), resulting in an 80.4% error rate."

Addressing Cascading Failures, ch. 22 · §"Latency and Deadlines" → "Bimodal latency"

A 5% fault became an 80% outage, and the multiplier was your own deadline. The thread is not spent on the broken thing; it is spent waiting for permission to give up. Which is also why work done past a client's deadline is not worth doing:

"you don’t get credit for late assignments with RPCs."

Addressing Cascading Failures, ch. 22 · §"Latency and Deadlines" → "Missing deadlines"

4. Retry amplification: the engine you have already met

Lesson 04 built this engine; here is what it does when bolted to a fleet. A backend caps out at 10,000 QPS and starts rejecting the excess. The callers retry. And then:

"The volume of retries grows: 100 QPS of retries in the first second leads to 200 QPS, then to 300 QPS, and so on."

Addressing Cascading Failures, ch. 22 · §"Retries"

An overload of 1% compounds into the whole load budget, and then the layers multiply:

"If the database can’t service requests because it’s overloaded, and the backend, frontend, and JavaScript layers all issue 3 retries (4 attempts), then a single user action may create 64 attempts (4^3) on the database."

Addressing Cascading Failures, ch. 22 · §"Retries"
Same arithmetic, different book

This is Lesson 04's 243 arriving from an entirely different company: five layers at three retries gives 3⁵ = 243, three layers at four attempts gives 4³ = 64. Two independent sets of engineers wrote down the same multiplication because it is not a bug in anyone's client — it is what a call graph does to per-layer policy. The fix is the same in both books: retry at one layer, and only the layer immediately above the rejection.

"requests should only be retried at the layer immediately above the layer that is rejecting them."

Handling Overload, Google SRE Book ch. 21 · §"Handling Overload Errors"

And a budget caps what is left. Google's is two numbers: at most three attempts per request, and at most 10% of a client's traffic may be retries.

"layering on the per-client retry budget (a 10% retry ratio) reduces the growth to just 1.1x in the general case"

Handling Overload, ch. 21 · §"Handling Overload Errors" → "Deciding to Retry"

3x worst case becomes 1.1x. That is the difference between an incident and an outage, bought with a counter.

5. Why "just restart it" so often fails

Here is the part that costs teams hours. A cascading failure has hysteresis: the load at which it starts is not the load at which it stops.

the load that starts it is not the load that stops it still crash-looping anywhere in here 1,000 QPS recovery begins 9,000 QPS still crashing 10,000 QPS was healthy here 11,000 QPS crashes begin The gap between 11,000 and 1,000 is the whole problem: capacity fell faster than demand did.
Everyone's instinct is to return traffic to a level the service was comfortable at yesterday. Yesterday's level was served by a full fleet with warm caches. Today you have a tenth of that, restarting into a wall.

"if a service was healthy at 10,000 QPS, but started a cascading failure due to crashes at 11,000 QPS, dropping the load to 9,000 QPS will almost certainly not stop the crashes. […] In this example, if 10% of the servers are healthy enough to handle requests, the request rate would need to drop to about 1,000 QPS in order for the system to stabilize and recover."

Addressing Cascading Failures, ch. 22 · §"Service Unavailability"

Restarting does not add capacity. It removes capacity now in exchange for capacity later — and "later" arrives cold, into the same traffic that killed the process the first time:

"The problem tends to snowball and soon all servers begin to crash-loop. It’s often difficult to escape this scenario because as soon as servers come back online they’re bombarded with an extremely high rate of requests and fail almost immediately."

Addressing Cascading Failures, ch. 22 · §"Service Unavailability"
scheduler fresh instance load balancer the obvious move — kill the unhealthy task, start a clean one health check failed → kill and restart starting up empty cache · no warm connections · JIT cold full share of traffic, from the first second 100% cache misses so every request goes to the slow backend health check unanswered again · seconds after boot the death spiral — the herd returns before the instance is ready the restart did not add capacity; it subtracted a warm instance and returned a cold one kill and restart · again Each cycle costs the cache, the connections and the warm-up. The loop is now strictly worse than before. Restarting only helps when the process itself is wedged — GC spiral, deadlock, deadline-less work.
The restart is not neutral. It trades a warm instance for a cold one during the exact minute when cold instances are most expensive — a cold cache converts one request into one backend call, at the moment the backend is the scarce thing.

Worse, the machinery that restarts for you is itself part of the loop:

"This practice may create a failure mode in which health-checking itself makes the service unhealthy."

Addressing Cascading Failures, ch. 22 · §"Stop Health Check Failures/Deaths"

The distinction that defuses it is one most services never make:

"Process health checking is relevant to the cluster scheduler, whereas service health checking is relevant to the load balancer."

Addressing Cascading Failures, ch. 22 · §"Stop Health Check Failures/Deaths"
Two different questions, two different endpoints

Is this binary alive? belongs to the scheduler, and its answer should cost nothing — no locks, no thread pool, no dependency call. Should this instance receive traffic right now? belongs to the load balancer, and may legitimately say no for a while. Wire one answer to both and you have built a machine that kills every instance of a service the moment its dependency slows down.

"Make sure that you identify the source of the cascading failure before you restart your servers. […] Canary this change, and make it slowly. Your actions may amplify an existing cascading failure if the outage is actually due to an issue like a cold cache."

Addressing Cascading Failures, ch. 22 · §"Restart Servers"

6. The escapes

Every escape does the same thing: it breaks one arrow in the loop. The order below is the order you reach for them in an incident.

load balancer instance scheduler 1 — shed, hard. Break the arrow from demand to threads. admit 1%, reject 99% 503 immediately · no thread, no queue slot a survivable trickle 2 — drain, then restart. Never the other way round. lame duck: stop new work, let in-flight requests finish restart now, at offset hash(host) mod 60 s — not all at once no requests were dropped and no two instances left together 3 — ramp. Let the cache fill before the herd is allowed back. 1% → 5% → 20% → 100%, checking latency at each step cache warm before full load so one request is one request, not one backend call Fix the trigger first. Ramp back into an unfixed cause and the cascade simply starts again.
Shed, drain, stagger, ramp. Notice that step 1 makes the outage worse for users on purpose, for a few minutes, in order to end it at all — which is why it needs to be a decision someone is authorised to make in advance, not a debate at 3am.

Shedding: reject early, cheaply, and without apology

Lesson 34 covered which requests to drop. This is the reason the mechanism has to exist at all — and the threshold has to be low enough to fire before threads are gone, not after.

"one effective approach is to return an HTTP 503 (service unavailable) to any incoming request when there are more than a given number of client requests in flight."

Addressing Cascading Failures, ch. 22 · §"Load Shedding and Graceful Degradation"

Shedding is not giving up; it is the only way to keep a promise:

"a backend task provisioned to serve a certain traffic rate should continue to serve traffic at that rate without any significant impact on latency, regardless of how much excess traffic is thrown at the task."

Handling Overload, ch. 21 · §"Conclusions"

"It's a common mistake to assume that an overloaded backend should turn down and stop accepting all traffic. However, this assumption actually goes counter to the goal of robust load balancing. We actually want the backend to continue accepting as much traffic as possible, but to only accept that load as capacity frees up."

Handling Overload, ch. 21 · §"Conclusions"

The trigger should be a signal the task owns. Google's default is the number of runnable threads compared to the number of processors — "executor load average" — because it notices saturation without needing to know what a request costs:

"We smooth this value with exponential decay and begin rejecting requests as the number of active threads grows beyond the number of processors available to the task."

Handling Overload, ch. 21 · §"Utilization Signals"

Headroom, and what it does not buy

Capacity planning sets how far the trigger is from normal. It is necessary and it is not sufficient, and the book says so in the same breath:

"Capacity planning reduces the probability of triggering a cascading failure, but it is not sufficient to protect the service from cascading failures."

Addressing Cascading Failures, ch. 22 · §"Preventing Server Overload"

Headroom buys minutes, not immunity. Minutes are enough — if shedding exists to use them.

Drain before restart, and stagger the restarts

Draining is the cheap half. Put the instance in lame-duck state first: it stops receiving new work, the requests already in flight complete, and only then does the process exit. Nothing is dropped, and the fleet loses one instance's worth of capacity rather than one instance's worth of in-flight requests as well.

Staggering is the other half, and it is Lesson 04's jitter applied to restarts. A rolling restart is a synchronised event by construction — and a synchronised restart is a trigger in its own right:

"Pushing a new version of the binary or updating its configuration may initiate a cascading failure if a large number of tasks are affected simultaneously."

Addressing Cascading Failures, ch. 22 · §"Process Updates"
# restart offset from a stable per-host value, not from random()
# same host, same slot, every time — so the incident stays reproducible
offset=$(( 0x$(hostname | md5sum | cut -c1-8) % 60 ))
sleep "$offset" && drain-then-restart

Drop traffic, then ramp it back

"Reducing load enough so that the crashing stops. Consider being aggressive here—if the entire service is crash-looping, only allow, say, 1% of the traffic through."

Addressing Cascading Failures, ch. 22 · §"Drop Traffic"

"When adding load to a cluster, slowly increase the load. The initially small request rate warms up the cache; once the cache is warm, more traffic can be added."

Addressing Cascading Failures, ch. 22 · §"Slow Startup and Cold Caching"
EscapeWhich arrow it breaksWhat it costs
Shed at the task (503 on in-flight limit)Demand → threads heldUser-visible errors, immediately and on purpose
Retry budget, one retrying layerFailure → more demandSome requests fail that a retry would have saved
Separate process and service health checksSlow dependency → instance removedA genuinely wedged task lives slightly longer
Drain before restartRestart → dropped in-flight workRestarts take longer; rollouts get slower
Jittered, canaried restartsRestart → synchronised capacity lossA rollout spans minutes instead of seconds
Drop to 1% and rampCold start → immediate full loadA deliberate, total outage for a few minutes
Capacity headroomTrigger → breaching the limitIdle hardware, permanently

7. Residual risk

Three things stay broken after you have done all of the above.

The escapes are code paths you never run. Degraded mode, the shedding threshold, the 1%-and-ramp runbook — all of them are exercised for the first time during the worst hour of the quarter:

"Remember that the code path you never use is the code path that (often) doesn’t work."

Addressing Cascading Failures, ch. 22 · §"Load Shedding and Graceful Degradation"

The stated remedy is uncomfortable and correct: "You can make sure that graceful degradation stays working by regularly running a small subset of servers near overload in order to exercise this code path."

Ramping back into an unfixed trigger just restarts the cascade. Shedding buys you a stable fleet, not a solved problem. If the cause was a capacity shortfall or a slow dependency, returning traffic returns the outage.

Every one of these mechanisms is also a way to fail. The chapter's closing remark is the sentence to leave with:

"Retrying on failures, shifting load around from unhealthy servers, killing unhealthy servers, adding caches to improve performance or reduce latency: all of these might be implemented to improve the normal case, but can improve the chance of causing a large-scale failure."

Addressing Cascading Failures, ch. 22 · §"Closing Remarks"

Read the list again. Retries, failover, health-check eviction, caching. That is not a list of mistakes — it is the standard resilience toolkit, and every item on it is a feedback path. Resilience features are how cascading failures travel.

8. Check yourself

9. Back to your world

Four questions that take an afternoon and change what your next incident looks like:

  • Does your readiness probe call a dependency? If it does, one slow backend can mark your entire fleet unready at once. Check whether the liveness probe does too — that one kills.
  • What happens at 2x your normal load? Not in theory. If you have never load-tested to breaking point, you do not know which resource runs out first, and you will find out at the worst moment.
  • Is there a way to shed? A concurrency limit that returns 503 before the queue fills is a few lines in most frameworks, and it is the difference between degraded and dead.
  • Does your deploy drain, and is it staggered? A rolling restart with no draining and no jitter is a synchronised capacity loss you perform on purpose, several times a week.
Ask me things. Good directions: "walk me through a cascade minute by minute with real numbers" · "what should a readiness probe actually check?" · "show me a concurrency limiter that sheds properly" · "how do I load-test to the breaking point safely?" · "I think restarting is usually the right first move. Grill me."