Backpressure, Load, and Cascading Failure

LESSON

Distributed Systems Foundations

012 20 min beginner

Backpressure, Load, and Cascading Failure

By the end of this lesson, you will be able to...

  • trace how one slow dependency can become overload across callers, queues, workers, and retries.

  • choose where to use admission control, bounded queues, concurrency limits, deadlines, and load shedding.

  • interpret operational signals that show whether a system is absorbing a burst or building a cascade.

Idea in one sentence: Backpressure protects a system by making overload visible and bounded before it turns accepted work into promises the system cannot keep.

Core Insight

A ride-hailing app shows drivers near a rider.

Every few seconds, the app asks an ETA service:

How long until driver D can reach rider R?

The ETA service calls a routing provider. Normally the provider answers in 80 ms. During a city event, the provider starts answering in 2 seconds. The ETA service does the friendly thing: it accepts every request and waits.

At first, this looks reasonable. Users want ETAs. Dropping requests feels bad.

Then the shape changes.

Clients time out and retry. Workers sit on slow calls. Connection pools fill. Queues grow. Some requests finish after the rider has already moved. The service is not only slow now. It is spending capacity on work that is no longer useful, while fresh requests wait behind old ones.

Plain meaning:

Backpressure is how a system says, "I cannot accept unlimited work right now. Some callers must slow down, wait with a deadline, receive a smaller answer, or be rejected."

In this scenario:

The ETA service may cap calls to the routing provider, reject some low-value refreshes with Retry-After, serve a cached ETA, or stop accepting requests whose deadline has already passed.

Technical name:

These controls are backpressure. They prevent cascading failure, where one slow component causes waiting, retries, and resource exhaustion across other components.

The Naive Idea: Accept Everything

The first design is simple.

mobile apps -> ETA API -> worker pool -> routing provider

The API receives a request. A worker calls the routing provider. The API returns the ETA.

Under normal load:

arrival rate:       1,000 ETA requests/second
provider capacity:  1,200 ETA responses/second
typical latency:    80 ms

The system has slack. A small burst is fine.

During the city event:

arrival rate:       1,600 ETA requests/second
provider capacity:    500 ETA responses/second
typical latency:     2 seconds
client timeout:      1.5 seconds

The provider can complete fewer requests than the service receives. That mismatch is the important fact.

A queue can smooth a short burst. It cannot create provider capacity. If arrivals stay above completions, the queue becomes a list of promises waiting to expire.

Check: If the ETA API accepts a request and puts it in a queue, has the system protected the user experience?

Think first, then reveal.

Answer: Not necessarily. A queue only helps if the work can still finish before its deadline and if the dependency has enough future capacity to drain the queue. Otherwise the system has accepted a promise that will probably become stale or time out.

The Cascade Starts With Waiting

Slow dependencies are not only slow. They hold resources while they are slow.

A worker waiting on the routing provider may hold:

When enough requests wait, the ETA service has fewer resources for everything else. The service becomes slow even for requests that might have been cheap.

Now retries arrive.

original user demand:  1,600 requests/second
client retry rate:       700 requests/second
observed API load:     2,300 requests/second

The system did not gain users. It gained repeated attempts from users who already waited too long.

The feedback loop looks like this:

routing provider slows
  -> ETA workers wait longer
  -> API latency rises
  -> clients time out
  -> clients retry
  -> ETA API receives even more work
  -> provider receives more active calls
  -> provider gets slower

This is cascading failure. The original problem may have been one dependency. The cascade is the feedback loop that spreads pressure across callers and services.

A Worked Trace: Break The Feedback Loop

Follow one overload window.

1. The Input Arrives

At 18:00, a concert ends. Many riders open the app.

18:00:00
incoming ETA requests: 1,600/sec
routing provider completion: 500/sec
client timeout: 1.5 sec

Naive decision:

Accept every request.
Start as many provider calls as workers allow.
Let clients retry on timeout.

Intermediate state after one minute:

oldest queued request:  45 sec
active provider calls:  4,000
provider p95 latency:   4 sec
client retries:         rising

Output:

Many riders see spinners. Some get ETAs for old locations. Drivers and riders refresh more often because the app looks stuck.

Naive failure contrast:

The API looked generous because it rejected almost nothing. In practice, it accepted more work than the system could make useful.

2. Put A Deadline On The Promise

An ETA has a short useful life. A route estimate that arrives 45 seconds late may be worse than no estimate. The rider and nearby drivers have moved.

So the request carries a deadline:

eta_request:
  rider_id: rider-9
  driver_id: driver-22
  requested_at: 18:00:02.000
  deadline: 18:00:03.500

The deadline travels with the work. A worker checks the remaining time before calling the routing provider.

if remaining_deadline < estimated_provider_latency:
    do not start provider call
    return cached_or_pending_response

This prevents expired requests from consuming scarce provider capacity.

3. Bound The Queue

The service also bounds waiting work.

queue policy:
  max queued requests: 20,000
  max oldest request age: 2 seconds
  drop expired ETA refreshes

The queue is allowed to absorb a short burst. It is not allowed to hide a long mismatch between arrival and completion.

When the queue age crosses the useful limit, the API changes behavior.

if queue_oldest_age > 2 seconds:
    reject low-value refreshes with 429
    include Retry-After: 3
    serve stale ETA if safe

The rejection is not the failure. The rejection is the system refusing to create a worse failure.

4. Protect The Dependency With A Concurrency Limit

The routing provider performs best with at most 800 active calls from this service. Above that, latency gets worse.

So the ETA service sets a concurrency limit:

provider concurrency limit: 800 active calls

This limit protects the provider from a stampede. It also protects the ETA service from filling its own worker pool with calls that will only make the provider slower.

A concurrency limit is different from a rate limit.

rate limit:        how quickly work may enter
concurrency limit: how much work may be active at once

Both can matter. A rate limit controls arrival. A concurrency limit controls pressure on the dependency.

5. Shed Lower-Value Work

Not every ETA request has the same value.

preserve:
  active trip pickup ETA
  driver navigation update
  rider confirmation after match

shed or degrade:
  map preview while browsing
  background refresh for inactive app
  repeated refresh from the same device

Load shedding means deliberately refusing, delaying, or simplifying lower-value work so the most important promises can survive.

The degraded response might be:

"Routes are updating slowly. Showing last known estimate: 7-9 min."

That response is not perfect. It is bounded, honest, and cheaper than making a new provider call that is unlikely to finish in time.

Check: Why is a stale ETA sometimes safer than accepting a fresh ETA request?

Think first, then reveal.

Answer: If the fresh request cannot finish before its deadline, it will consume scarce capacity and may return too late to be useful. A clearly labeled stale ETA gives the user bounded information while preserving capacity for work that still matters.

6. Make Retries Part Of The Design

Retries are useful for short, random failures. They are dangerous when every client retries at the same time.

The service gives callers a retry policy:

retry only retryable failures
wait with jitter
honor Retry-After
stop at the deadline
limit retry attempts per original request

The goal is to avoid synchronized waves.

Without jitter:

1,000 clients fail at 18:00:01
1,000 clients retry at 18:00:02
1,000 clients retry again at 18:00:04

With jitter and a deadline, retries spread out and stop when the answer would no longer help.

Signals That Tell The Truth

During overload, one metric rarely tells the whole story.

Queue depth matters, but it is not enough. A queue of 10,000 requests might be fine for a batch import and terrible for ETA refreshes.

Useful signals include:

The most important comparison is often:

Can the system complete useful work faster than useful work arrives?

If not, accepting more work makes the incident larger.

Common Confusions

Confusion: A Queue Is Capacity

Why it is tempting:

A queue makes the API look calm for a while. Requests have somewhere to go.

Better model:

A queue is a buffer, not a new worker. It buys time during short variation. During sustained overload, it records how many promises are waiting to become stale.

Confusion: Backpressure Means Giving Up

Why it is tempting:

Rejecting or degrading a request feels worse than accepting it.

Better model:

Backpressure is choosing a smaller honest promise over a larger false one. It protects the work that still has a chance to succeed.

Confusion: More Workers Always Help

Why it is tempting:

If work is waiting, more workers sound like the obvious fix.

Better model:

More workers help only when the bottleneck can use more parallelism. If the routing provider is already saturated, more workers increase contention and waiting.

Trade-offs And Limits

Backpressure improves survival under pressure. It does not create infinite capacity.

The central trade-off is success rate versus truthfulness under overload.

If the service accepts every request, the short-term success rate at the API boundary may look high. More callers receive 200 OK. But many of those accepted requests are now fragile promises. They may wait too long, return stale data, time out in the client, or steal capacity from a request that still had a useful deadline.

If the service rejects, delays, or degrades earlier, the API boundary looks less successful. Some users see a controlled failure or a stale estimate. But the system has told the truth about current capacity and preserved the path for higher-value work.

It costs product and operational decisions:

It can still fail if priorities are wrong, retry policies are ignored, dependencies do not expose health signals, or degraded responses are misleading.

Recovery also needs backpressure. When the routing provider improves, the ETA service should not instantly open every gate. It should raise admission and concurrency gradually while watching queue age, provider latency, retry rate, and error rate.

recover in steps:
  keep expired work out
  drain high-priority useful work
  raise low-priority admission gradually
  restore background refresh last

The boundary is visible when queue age and deadline expiry keep rising even after rejections begin. That means the system is still accepting or retaining more work than it can make useful.

Practice: Place The Pressure

Take this design:

mobile app -> ETA API -> queue -> workers -> routing provider

Fill in a policy:

maximum useful ETA age:
queue depth limit:
provider concurrency limit:
requests to preserve:
requests to shed or degrade:
client retry rule:
signal that recovery may begin:

Model answer:

maximum useful ETA age: 2 seconds for active matching, 10 seconds for map preview
queue depth limit: enough for a short burst, but reject if oldest request > useful age
provider concurrency limit: the tested level before provider p99 latency rises sharply
requests to preserve: active pickup, matched trip, driver navigation
requests to shed or degrade: inactive background refresh, map browsing, repeated refreshes
client retry rule: honor Retry-After, jittered retry, stop at request deadline
signal that recovery may begin: provider p99 falls, queue age drains, retry rate drops

Now change one parameter: the routing provider returns to normal latency, but clients keep retrying aggressively for five more minutes.

The system should keep retry budgets and admission limits active until observed retries fall. A recovered dependency can be overloaded again by stale client behavior.

Resources

Key Takeaways

PREVIOUS Observability and Debugging Distributed Systems NEXT Schemas, Contracts, and Versioned Messages