Distributed SystemsField engagement

When adding servers stops helping, everything is waiting on one shared resource, and the work is finding which one.

Fractional CTO and lead engineer.
74%
of request time, in the database
p99
not the mean
no
model anywhere
averagep99the kneeloadlatency
Under load the average looks fine while the tail falls off a cliff. Users feel the slowest requests, not the mean.
What was at stake

Users feel the slowest requests, not the average one. Under contention the slow tail stretches long before the mean moves, so dashboards built on averages look survivable while a meaningful share of customers are watching a page that will not load. Then the second-order effect arrives. Slow responses make clients retry, the retries add load, the extra load slows everything further, and a slow system becomes a down one. All the while the cloud bill was rising, because the response to slowness had been to add capacity, and the added capacity was not helping.

How this works

Throughput rises with capacity until something shared fills up, and then waiting time goes nearly vertical.

Any system that serves requests is a set of queues. A request arrives, waits for a resource, uses it and moves on. When a resource is lightly used, waits are rare and short. As its utilisation climbs toward full, the waits do not grow steadily. They grow slowly for a long time and then very quickly, because each new arrival is more likely to find others already in line, and the line has less and less spare time in which to clear. That bend is called the knee, and every shared resource has one.

Adding servers helps only with resources that are not shared. A stateless application server handles its own requests and depends on nobody else, so doubling the servers roughly doubles the work they can do. A database primary, a lock or a hot row is different. Every server added sends more requests to the same place, where they wait for each other. Past a point, the overhead of coordinating grows faster than the useful work, and total throughput can fall as capacity is added. That is the counterintuitive regime in which an autoscaler, seeing slowness and responding with more servers, deepens the problem it is trying to solve.

Averages hide all of this. When a resource is near its knee, most requests still pass quickly and a few wait a very long time, so the mean barely moves while the slowest requests get dramatically slower. That is why the measure that matters is a high percentile such as p99, the time within which all but the slowest one percent of requests finish. It is also why retries are dangerous. A client that gives up and tries again adds a second copy of its demand to a resource that is already full, and enough clients doing that turn a slowdown into an outage.

Stateless tier
Add an application replica
it brings its own processor and memory
No shared waiting
requests do not queue behind each other
Throughput rises in proportion
the straight line
Contended shared resource
Add an application replica
it sends more queries to one primary
Queues form at the database
locks, hot rows, a single writer
Throughput flattens, then falls
and the bill still rises
Same action, opposite result
An autoscaler cannot tell these two situations apart. A trace can.
FigureThe same move, adding replicas, in two different situations. When the work is independent each replica brings its own capacity. When every replica queues for the same database, each one adds contention, and throughput can bend downward while the bill goes up.
The constraint

A stateless tier scales in a straight line. Add a replica and you get roughly proportional throughput. That stops being true the moment requests start queuing for one shared thing: a database primary, a lock, a single hot row. From then on every new replica adds contention instead of capacity, throughput bends downward, and the autoscaler makes things worse while reporting that it is working. So the useful question was never how much more capacity to buy. It was which resource everything is waiting on, and that question has an answer you can measure.

app tier12%
cache8%
waiting on the database74%
network6%
Where the request time actually goes. Scaling the app tier does nothing when the system is waiting on the database.
Where it usually breaks

Overloaded systems fail through feedback loops, and the dashboard is usually the last to know.

Systems under load rarely fail in proportion to the load. They fail through loops. A resource nears its knee, the tail stretches, clients time out and retry, the retries add load, and the resource slides further past its knee. Each turn of the loop makes the next one worse, which is how a busy afternoon becomes an incident.

The response that feels safest often feeds the loop. Adding capacity to the tier that is easiest to scale makes the dashboard show effort, and if that tier is not the bottleneck it sends even more traffic into the resource that is. Because averages move late, the mistake can run for a long time before anyone challenges it, visible mainly in the cloud bill.

01

The average that looks fine

Mean latency stays flat while the slowest requests become unusable. Dashboards built on averages report health during the period users are leaving.

02

The retry storm

Clients that time out try again, adding load to a resource that is already full. Without limits on retries, a slowdown becomes an outage.

03

Scaling the wrong tier

Replicas go to the tier that is easiest to scale. If the bottleneck is elsewhere, each one adds contention and cost and no capacity.

04

State pinned to a server

A replica holding session or local state cannot be moved or removed freely, so the tier that should be elastic is not.

05

The unbounded queue

A queue with no limit absorbs a spike by growing until memory or patience runs out, then fails all at once and takes its backlog with it.

  1. Database nears its knee
    queries begin to wait for each other
  2. The tail stretches
    p99 climbs while the mean barely moves
  3. Clients time out and retry
    the same work arrives again
  4. Autoscaler adds app replicas
    more connections into the same primary
  5. Database falls further behind
    the loop tightens
  6. Slow becomes down
    and the bill has gone up
Shedding load deliberately breaks this loop. Adding capacity in the wrong place feeds it.
FigureHow a slow database becomes a down system. No single step is dramatic. Each one adds load to the resource that is already the constraint, and adding application replicas joins the loop at the worst possible point.
The fork

The reflex, and the fix.

Road not taken

Add replicas and autoscale the app tier

Pull

One configuration change. It buys a little time at first and feels like progress.

Why not

Once the database is the bottleneck, more application replicas pile more load onto it. The cliff gets steeper and the bill keeps climbing.

Road taken

Find the saturated resource, then fix it

Accepted

Measuring before changing anything, fixing the constraint that is actually binding, and accepting that the bottleneck will move and the work will need doing again.

Bought

Throughput that rises when capacity is added, and a system that sheds load deliberately when it is full, so overload no longer cascades.

Decision

Scale the system the load actually has, found by measurement, and not the one the dashboard makes easy to scale.

How it was built
01Diagnose
›
02Stateless tier
›
03Data tier
›
04Async + backpressure
›
05Protect
›
06Re-measure
re-measure, the bottleneck has moved
01

Measure before scaling anything

Distributed tracing across the request path showed where the time went. 74% of request time was spent in the database, not in the application tier that had been receiving the new replicas. Once that was on a trace and out of the realm of opinion, the next move was obvious and the spending stopped going to the wrong place.

02

Make the easy tier actually easy

A replica that holds session or local state cannot be added, moved or removed freely. Pushing that state into a shared store makes application servers disposable, so that tier scales in the straight line it was always supposed to.

03

Treat the data tier as the wall

Reads are the tractable half. Index and query work comes first, then connection pooling, read replicas and caching, each trading a little staleness for a lot of headroom. Writes are the hard half, because a single consistent primary has a ceiling, and getting past it means partitioning, the most powerful and most expensive move available.

04

Take work off the request path

Only the work a user is waiting for belongs on the request path. Everything else goes to a bounded queue that absorbs spikes and lets workers drain it at a safe pace. When the queue is full the system sheds load on purpose, because a refused request can be retried and a collapsed system cannot.

Inside the key decisions

What each choice cost, and what it bought.

ATracing before touching anything

The pressure was to act, and an obvious action was already in motion. Pausing to instrument the request path end to end costs time while users are suffering, and it produces no visible improvement on its own. What it bought was a fact, 74% of request time in the database, that ended the argument about where to spend. Without it every later move would have been a guess, and the most likely guess, more application capacity, was the one making things worse.

BMaking the application tier stateless first

Pushing session and local state into a shared store is unglamorous work, and it adds a dependency. It is also what makes every later decision cheap. Once application servers are disposable they can be added, removed and replaced without ceremony, and the remaining scaling problem is concentrated in the one place that is genuinely hard.

CWorking the data tier from the cheapest move to the most expensive

Partitioning is the most powerful tool against a write ceiling and the most tempting one for an engineer. It also adds complexity that never goes away. Working through query and index fixes, pooling, read replicas and caching first means each step is reversible, and each trades a small amount of freshness for headroom. Partitioning stays on the table as a last resort, for when a single consistent primary has genuinely run out of room.

DShedding load on purpose

A system that accepts every request will eventually accept more than it can finish, and then it fails everyone. Bounding the queue and refusing work when it is full means some users see an error at the peak. That is a real cost. It is much smaller than the alternative, because a refused request can be retried later and a collapsed system serves nobody until it is brought back.

Index and query workfirst, cheapest and fully reversible
Connection poolingfewer, better-used connections to the primary
Read replicasreads move off the primary, at the cost of slight staleness
Cachingrepeated reads never reach the database
Partitioning as a last resortthe only way past a single write ceiling, and permanent complexity
Reads are the tractable half. Writes are where the wall is.
FigureThe moves available on the data tier, ordered from cheapest and most reversible at the top to most powerful and most permanent at the bottom. Each step trades a little freshness or simplicity for headroom. Partitioning sits at the bottom because its complexity never goes away.
How it was measured

A single headline number hides where a system fails. This work was scored on the dimensions that actually decide whether it holds in production, measured on real, held-out cases rather than the demo path.

p99 and p999 latencyThroughput at SLOHeadroom to the next kneeLoad shed versus collapse past the limit
Request arrives
load balancing and routing
Application tier
where the new replicas had been going
Database
74%
of request time, found by tracing
Response returns
judged at p99, not the mean
The measure that mattered was the tail. The tool that found the cause was the trace.
FigureWhat the trace showed. A request spends a little time at the edge and in the application tier, and most of its life waiting on the database. 74% of request time sat in the data tier, which is why the replicas being added to the application tier were not helping.
figures

What it produces
Without this discipline

A rising cloud bill, a tail that still spikes, and an outage every time traffic jumps, because the contended resource was never the application tier that kept getting scaled.

This system

Throughput that rises with capacity because the binding constraint was fixed, a tail that holds under load, deliberate shedding where there used to be cascades, and a known answer to where the next wall is.

diagnose first74% found by tracingthe data tier is the walldegrade gracefully
The operating envelope

What it owns, and what it hands to a person.

Handled with confidence
Load within modeled capacity
The diagnosed bottleneck addressed
Tail within SLO
Flagged for review
Approaching the next knee
Behavior past the modeled limit
Out of scope by design
The ceiling of a single consistent write path
Premature sharding
The honest limit

Scaling is iterative. Fix one bottleneck and the next one becomes visible, so there is no final state, only the next constraint. Some limits are fundamental. A strongly consistent write path does not scale forever, and partitioning buys throughput at the price of real and permanent complexity, which is why it is a last resort and never a first move.

What it generalizes to

Nothing in this work could have been sold by an AI vendor or solved by a model. It is measurement, contention and the architecture of the data tier. It also shows what diagnosis is for, which is preventing an expensive wrong action. Here the reflex, adding capacity, was already underway, already costing money and already making the failure worse.

What to take from this

If adding servers does not raise throughput, something shared is saturated, and more servers will usually make it worse.

Measure where request time is spent before spending money on capacity. A trace turns a debate about the bottleneck into a fact.

Watch high percentiles of latency and not the mean. The tail degrades first, and it is what users actually experience.

Make the application tier stateless so it can scale freely, and expect the hard scaling problem to sit in the data tier.

Bound every queue and shed load deliberately when it is full. A refused request can be retried, and a collapsed system cannot.

How we engage

You have a system like this one.
Tell us where it stands.

Whether it is failing, not yet built, or about to meet a scale it has never seen, we can tell you what we see.

Start a conversation
mostafa@opulion.dev · Response within 24 hours · By inquiry