Reference · Mechanics · Part 4 of 6

Overload & runaway starvation

Part 2 gave you the ceiling: ~30 requests/second. This part is about what happens when the world pushes past it — and why the failure doesn't just get "a bit slower," it spirals.

The confusion that trips everyone up here is that two completely different numbers both get called "requests per second." Untangle them and the rest of this page falls into place.

Two rates: the drain and the tap

There is a rate at which the node can serve work, and a rate at which work arrives. They have nothing to do with each other, and the entire drama is a comparison between them.

RateWhat it isWho sets itValue here
DrainThe service rate — the most the node can possibly serve.The bug: 3 workers, 2 calls each. Nothing external can raise it.~30 req/s
TapThe arrival rate — how much load is pushed at the node.The outside world: clients and the load balancer. Nothing to do with the pool.28 or 35 req/s

So when we talked about "28 req/s" and "35 req/s," those were both tap values — arrival rates I chose to sit either side of the fixed ~30 drain. The only question that matters is: is the tap faster or slower than the drain?

The sink

Think of a sink. The drain empties 30 litres a minute — fixed, you can't change it.

Tap at 28 L/min (below the drain): water leaves as fast as it arrives. The level sits near the bottom and stays there. Stable forever.

Tap at 35 L/min (above the drain): every minute, 5 more litres arrive than can leave. The level rises — 5, 10, 15, 20 litres — and it never stops rising, until it overflows onto the floor. There is no water level at which 35 in / 30 out balances. It can't settle.

Requests behave exactly like the water. Below the drain, the queue stays near empty and latency is just the service time. Above it, the backlog grows every single second:

arrivals 35/s, drain 30/s  →  +5 requests pile up every second
   after 10s:   50 waiting
   after 60s:  300 waiting
   after 2m :  600 waiting     → latency now measured in minutes
   after ... :  it never stops — the backlog has no upper bound

arrivals 28/s, drain 30/s  →  queue ≈ 0 the whole time (stable)
+5 req/s piling up every second at 35 req/s — a backlog with no ceiling. At 28 req/s the same graph is a flat line at zero.

A cliff, not a slope

This is why 28 is healthy and 35 is doomed. It isn't "35 is a little worse than 28." They are on opposite sides of a cliff. Below the drain, the queue has a stable resting point (near zero). Above the drain, the queue has no upper bound at all — it only grows. A 25 % bump in load doesn't cost you 25 % more latency; it moves you from "fine forever" to "collapses eventually." The ceiling from Part 2 is the edge of that cliff.

Why it runs away instead of settling slow

If the backlog merely grew at a steady +5/s you might notice and react. What makes it accelerate is a positive feedback loop — the slowness feeds more load back into the system:

  1. Backlog grows → responses get slower → each Tomcat thread is held longer. Eventually all of Tomcat's request-handling threads are stuck waiting, and you hit 200/200 busy (Part 1). The front door is now jammed.
  2. Health checks start failing → the load balancer pulls the node. Its traffic is dumped onto the other nodes, so their tap jumps above 30 too, and they start running away as well (the cascade — Part 5).
  3. Timed-out requests get retried → the tap goes up. Every client that gives up and retries is adding load. This is a "retry storm": the tap that was 35 becomes 45, then 55 — pushing you further over the drain exactly when you least want it.

Notice the shape of it. Over-ceiling makes the node slower; slower triggers thread exhaustion, node evictions, and retries; and every one of those pushes the arrival rate even higher. It doesn't converge to a "slow but stable" state — it spirals downward, faster and faster. That spiral is what "runaway starvation" means. (In the literature this shape is called a metastable failure: the system looks fine right up until a small nudge tips it into a self-sustaining collapse that persists even after the original nudge is gone.)

Honest footnote — why our own tests didn't blow up

The loadp.py runs earlier were closed-loop: each client waits for its response before firing the next request. That means arrivals can never truly outrun the drain — the number of requests in flight is capped at the client count. That's why those tests get slow (a 17-second tail) but stay bounded. Real production traffic is open-loop: users and load balancers keep firing at a fixed rate whether or not the node is keeping up. Runaway is an open-loop phenomenon — you need a tap that ignores the drain to make the sink overflow.

Take away

Overload is not "a bit slower." When the tap (arrivals) exceeds the drain (~30 req/s service rate), the backlog is unbounded — there is no stable resting point above the ceiling.

And it accelerates: thread exhaustion, node evictions, and client retries all feed more load back in, turning a small overshoot into a self-sustaining spiral.

Primary source Little's Law — the same L = λW that fixes the drain also says queue length and wait time grow without bound once the arrival rate exceeds the service rate. Two terms worth searching to go deeper on the spiral itself: "retry storm" and "metastable failures".
Your teacher is in the chat. Ask and I'll fire an open-loop load above the ceiling so you can watch fjp_common_queued climb without bound and latency march toward minutes — then drop it below the ceiling and watch the same graph flatten to zero. Seeing the backlog go vertical vs stay flat is usually when "runaway" stops being a word and becomes a picture.
PrevPart 3: The signature of queueing Contents NextPart 5: Blast radius