Back to blog
Architecture
AdvancedForBackend EngineersPlatform EngineersSRE
14 min

Scalability: The Bottleneck Is Almost Never the CPU

Why CPU sits at 30% while your service times out: the utilisation knee, connection pools, ephemeral ports, coordinated omission in your load tests, and the commands that find the binding constraint.

scalabilityload-balancingdistributed-systemsconnection-poolbackpressurequeueing-theory
Contents

Your dashboard shows 28% CPU. Your p99 latency is 4 seconds. Someone suggests adding more pods.

This is the most common wrong turn in scaling work, and it comes from a reasonable assumption: that a slow system must be a busy system. It usually is not. It is a system where requests are queuing for something that is not CPU, and adding instances often makes it worse.

Here is what is actually limiting you, and the arithmetic to find it.

Scalability bottlenecks: why CPU is idle while requests queue elsewhere.

One formula that sizes almost everything

Little’s Law is the most useful thing in capacity planning, and it fits on one line:

concurrency = arrival rate x latency
L = λ × W

If 500 requests arrive per second and each spends 40 ms in the database, then at any instant you need:

500 req/s × 0.04 s = 20 concurrent database connections

Twenty. Not two hundred.

Now run it backwards, which is where it earns its keep. You have a pool of 20 connections and each query takes 50 ms:

20 / 0.05 s = 400 requests per second

That is your ceiling. Not a soft limit — a hard one. You can run forty pods; the pool still admits 400 requests per second and everything else waits. CPU stays low because the CPU is not doing anything. It is waiting.

Every queue in your system obeys this. Thread pools, connection pools, worker counts, in-flight request limits. If you know two of the three terms, you know the third.

Why 80% utilisation is not 80% fine

Here is the part that explains why capacity planning feels counter-intuitive.

For a simple queue, response time relates to utilisation like this:

response time = service time / (1 - utilisation)
R = S / (1 - ρ)

That denominator is the whole story. As utilisation approaches 1, response time does not rise gently — it goes to infinity.

Why 80% utilisation is not 80% fine
Utilisation Response time vs. an idle system
50%
80%
90% 10×
95% 20×
99% 100×

Going from 50% to 80% utilisation does not cost you 30% of your latency budget. It costs you 150% — response time goes from twice service time to five times it.

This is why experienced teams keep what looks like wasteful headroom, and why a system that was fine yesterday at 70% falls over today at 85% after a modest traffic increase. Nothing changed except your position on a curve that turns vertical.

The honest caveat: this exact formula assumes a single server with Poisson arrivals and exponentially distributed service times — an M/M/1 queue. Real systems have multiple servers and burstier traffic, so your constants will differ. But the shape is universal: queueing delay is hyperbolic in utilisation, not linear. Every queue you own has a knee, and it is closer to the left than intuition suggests.

Practical consequence: stop setting autoscaling targets at 80% CPU. By the time you are there you are already deep into the curve, and the new instance takes time to become useful.

The ceilings, in the order they usually bite

1. Database connections

This is the most common real limit, and it has a number you can look up.

PostgreSQL defaults to max_connections = 100, of which 3 are reserved for superusers by default. So roughly 97 are actually available to your application.

Now count what you are asking for. Twelve pods with a pool of 10 each is 120 connections against a limit of 97. The twelfth pod gets connection errors, and the error looks like a database problem rather than an arithmetic one.

Two things make this sharper:

  • PostgreSQL forks a process per connection, so connections cost real memory. The docs are explicit that raising max_connections raises shared memory allocation with it. This is why a pooler like PgBouncer exists.
  • A bigger pool is not more throughput. Past the point where the database can genuinely execute queries in parallel, extra connections add lock contention and context switching. Throughput flattens, then falls.

MySQL uses a thread per connection and is cheaper here, which is one of the structural differences between Postgres and MySQL worth knowing before you pick.

2. File descriptors

Every socket, every open file, every connection is a file descriptor. The per-process limit is often 1024 by default on the soft limit, which sounds generous until you notice it covers inbound connections, outbound connections, log files and library internals simultaneously.

Terminal window
ulimit -n # soft limit for this shell
cat /proc/<pid>/limits # what the running process actually has
ls /proc/<pid>/fd | wc -l # how many it is using right now

The failure mode is memorable: EMFILE: too many open files, usually under exactly the load where you least want to be reading a stack trace.

3. Ephemeral ports and TIME_WAIT

This one surprises people because nothing in the application mentions it.

When your service opens an outbound connection, the kernel picks a source port from the ephemeral range. On Linux:

Terminal window
cat /proc/sys/net/ipv4/ip_local_port_range
# typically: 32768 60999

That is about 28,000 ports per unique destination address and port combination.

Then the closing side holds the socket in TIME_WAIT for 60 seconds — and on Linux that duration is compiled into the kernel, not a sysctl you can turn down.

Do the arithmetic. Open 500 short-lived connections per second to the same downstream service:

500 conn/s × 60 s = 30,000 sockets in TIME_WAIT

You have run out. New connections fail, and the error will be a connect timeout that looks like the downstream service is down.

The fix is not raising the range. It is not opening the connections: HTTP keep-alive, connection pooling, HTTP/2 multiplexing. Reuse beats provisioning.

4. Connection tracking

If your traffic passes through a NAT gateway, a firewall or a Kubernetes node using iptables, the kernel keeps a conntrack entry for every flow. That table is finite, and when it fills, packets are dropped silently — no error, no log line in your application, just latency and retries.

Terminal window
sysctl net.netfilter.nf_conntrack_max
sysctl net.netfilter.nf_conntrack_count

The gap between those two numbers is a metric worth alerting on, and almost nobody exports it.

5. Threads and event loops

A thread-per-request server is a pool by another name, and Little’s Law applies unchanged. An event-loop server has no such limit — which is its own hazard, because it will happily accept ten thousand concurrent requests and serve all of them slowly instead of refusing some quickly.

One blocking call on an event loop stalls every request on that loop. The CPU graph will look calm throughout.

Horizontal scaling and the shared denominator

Adding instances multiplies capacity for the independent part of a request and does nothing for the shared part.

Amdahl’s Law puts a ceiling on the speedup available from parallelising anything with a serial fraction. The Universal Scalability Law adds the part people miss, and it is worth writing out because the second term is the one that bites:

C(N) = N / (1 + σ(N-1) + κN(N-1))
N number of workers
σ contention — the serial fraction, queueing for a shared resource
κ coherency — the cost of keeping workers consistent with each other

With κ = 0 this reduces to Amdahl’s Law: throughput flattens but never falls. With κ > 0 something worse happens — there is a maximum, and past it throughput decreases:

peak at N* = sqrt((1 - σ) / κ)

The coherency term is why adding capacity can make a system slower. It covers cache-line bouncing between cores, lock handoffs, cluster gossip, distributed consensus, cross-region replication — anything whose cost grows with the square of the number of participants because each one must agree with every other.

Concretely, if every new pod opens more connections to the same primary database, you are not scaling. You are converting a connection limit into a queue and then into a timeout. If every new node must gossip with every other node, you have bought yourself a κ term.

Ask this before adding instances:

  1. What does a request touch that is shared?
  2. What is the current concurrency limit on that shared thing?
  3. Does another instance raise that limit, or compete for it?

If the answer to 3 is “compete”, scaling out makes it worse.

Load balancing: what the diagrams leave out

The box labelled “LB” in your architecture diagram hides three decisions that determine whether scaling works.

Health checks that lie. A check that returns 200 whenever the process is alive will keep routing traffic into an instance whose database pool is exhausted and whose queue is thirty seconds deep. A useful check verifies the dependencies a request actually needs — and distinguishes liveness (restart me) from readiness (stop sending me traffic). Conflating them causes restart loops under load, exactly when you need stability.

L4 versus L7 under HTTP/2. L4 balances connections. With HTTP/2 or gRPC, one long-lived connection carries many multiplexed requests — so connection-level balancing can leave one backend doing most of the work while the others idle. The graph shows even connection counts and wildly uneven CPU. For HTTP/2 and gRPC you generally want request-level balancing.

Sticky sessions. Pinning a user to an instance defeats balancing on purpose. It also means a restart drops those users’ state, and a hot user becomes a hot instance you cannot spread.

Then there is the retry trap: when a system is already saturated, automatic retries multiply the load that saturated it. Retries need rate limiting and a budget, plus jitter, or they turn a brown-out into an outage.

Queues absorb bursts, not overload

A queue in front of a slow consumer buys you time across a burst. It does nothing about a sustained arrival rate above your service rate — it just moves where the failure appears.

If arrivals exceed service capacity indefinitely, the queue grows without bound, and latency grows with it. Callers time out. They retry. Arrivals increase. This is a feedback loop with the sign pointing the wrong way.

Backpressure is the answer, and it means being willing to say no:

  • Bounded queues. An unbounded queue is an out-of-memory error with extra steps.
  • Reject fast. A 429 with Retry-After in 5 ms is far kinder than a timeout at 30 seconds — to the caller and to you.
  • Shed by value. Under pressure, drop the batch export before you drop the checkout.
  • Cap in-flight work. Concurrency limits per dependency stop one slow downstream from consuming every worker you have.

The instinct is that rejecting requests is failure. The alternative is accepting requests you cannot serve, which is also failure — just later, and for everyone at once.

What multi-region does and does not give you

Multi-region buys you availability when a region fails and lower latency for users near a region.

It does not buy you throughput on anything that must stay consistent. A globally consistent write still costs at least one cross-region round trip, and the speed of light is not a configuration parameter — roughly 5 ms per 1,000 km in fibre before any equipment touches the packet. Frankfurt to Virginia is a physical floor of tens of milliseconds, per round trip.

If your write path crosses regions, you have not scaled writes. You have added latency and a partition mode to test.

Finding the actual bottleneck

Brendan Gregg’s USE method is the fastest route. For every resource, measure three things:

Finding the actual bottleneck
What it means Why it matters
Utilisation % of time the resource is busy Misleads on its own
Saturation How much work is queued Usually the real signal
Errors Rejections, timeouts, drops Tells you what already broke

Most teams export utilisation and stop. But a pool at 100% utilisation with an empty queue is perfectly healthy, while a pool at 60% with a queue thirty deep is your outage. Saturation is the metric that names the bottleneck, and it is the one almost nobody graphs.

Practical version: for every pool in your system — database connections, HTTP clients, thread pools, worker queues — export the queue depth and the wait time, not just the in-use count.

Your load test is lying to you

This one deserves its own section, because it invalidates the measurement most teams trust most.

Most load generators work in a closed loop: send a request, wait for the response, send the next one. It seems reasonable. It produces latency numbers that are quietly, systematically wrong.

Picture a generator with 100 virtual users hammering a service that stalls for 2 seconds. During that stall, each user is blocked waiting. They send nothing. When the stall clears, everyone resumes and records a normal-looking latency.

The result: the worst two seconds of your test contributed almost no samples. Your p99 is computed over a set of measurements that excludes the period you actually care about.

This is coordinated omission, named by Gil Tene. The load generator has unwittingly coordinated with the system under test to only sample it when it is healthy.

Real users do not behave that way. They arrive on their own schedule. A user who clicks during your 2-second stall waits the full 2 seconds — plus however long the queue in front of them takes to drain.

How to tell if you have it: if your p99 under load is suspiciously close to your median, and your users report freezes you cannot reproduce, you almost certainly do.

The fixes:

  • Measure against the intended schedule, not the actual one. If you meant to send a request at t=0 and only sent it at t=1.8s because you were blocked, that request’s latency starts at t=0.
  • Use a corrected tool. wrk2 was written specifically for this. HdrHistogram provides recordValueWithExpectedInterval to backfill the missing samples.
  • Prefer an open model. Generate load at a fixed arrival rate regardless of whether previous responses came back — which is what real traffic does.

Until you fix this, every latency number you have collected under saturation is an underestimate, and the worse the stall, the bigger the lie.

Finding it in practice

Enough theory. Here is the actual sequence when latency is up and CPU is not.

Start with the database, because it usually is.

-- What is everyone waiting on, right now?
SELECT state, wait_event_type, wait_event, count(*)
FROM pg_stat_activity
GROUP BY 1, 2, 3
ORDER BY 4 DESC;

A pile of rows in Client / ClientRead means the database is idle and waiting on you — the bottleneck is elsewhere. A pile in Lock or LWLock means contention inside the database. Many idle in transaction means your application is holding connections open without using them, which is the same as having a smaller pool than you think.

-- How much connection headroom is left?
SELECT count(*) AS used,
current_setting('max_connections')::int AS max_conn
FROM pg_stat_activity;

Then the sockets.

Terminal window
ss -s # totals, including TIME_WAIT
ss -tan state time-wait | wc -l # how deep is the TIME_WAIT hole
ss -tan state established | wc -l # what you actually have open

If TIME_WAIT is in the tens of thousands, you are not reusing connections. That is a client configuration problem, not a capacity problem.

Then the kernel tables.

Terminal window
cat /proc/sys/net/ipv4/ip_local_port_range
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max

Then the process limits.

Terminal window
cat /proc/<pid>/limits | grep 'open files'
ls /proc/<pid>/fd | wc -l

The whole sequence takes about three minutes and will identify the binding constraint far more often than another dashboard will.

The checklist

The checklist
Symptom Likely cause First thing to check
Low CPU, high latency Queuing on a pool Pool wait time, queue depth
Errors at N pods but not N-1 max_connections exceeded Pods × pool size vs DB limit
too many open files FD limit ulimit -n, /proc/<pid>/limits
Connect timeouts to one downstream Ephemeral ports / TIME_WAIT Connection reuse, keep-alive
Silent packet loss under load conntrack table full nf_conntrack_count vs max
Uneven backend CPU, even connections L4 balancing HTTP/2 Move to request-level balancing
Latency grows, never recovers No backpressure Bound the queue, shed load
Adding pods makes it slower Shared-resource contention What do all pods compete for?

The bottom line

CPU is the easiest thing to graph, which is why it is the first thing people look at and the last thing that is usually wrong.

Requests queue for connections, descriptors, ports, locks and threads — and every one of those queues follows the same arithmetic. Concurrency equals arrival rate times latency. Know two terms and you know the third, which means you can calculate your ceiling instead of discovering it during an incident.

Before adding capacity, answer one question: what is the request waiting for? If you cannot name it, another instance will not fix it — it will just give the same queue more customers.

Frequently asked questions

Why is my CPU low but the service is slow?

Because CPU is only one of several resources a request needs, and it is rarely the scarcest. A request typically waits on a database connection from a pool, a file descriptor, an ephemeral port, a thread or a lock. When any of those is exhausted, requests queue while the CPU sits idle waiting on I/O. Low utilisation next to high latency is the signature of a saturated queue somewhere else, which is why the USE method tells you to measure saturation separately from utilisation.

How do I size a database connection pool?

Use Little's Law: concurrency equals arrival rate multiplied by average service time. At 500 requests per second with 40 ms spent in the database, you need 500 times 0.04, which is 20 concurrent connections. Then check the total across every instance: twenty pods with a pool of 20 each demands 400 connections, and PostgreSQL defaults to 100. Bigger pools are not the fix — beyond the point where the database can execute queries in parallel, extra connections add contention rather than throughput.

What are ephemeral ports and why do they limit throughput?

When your service opens an outbound connection, the kernel assigns it a source port from the ephemeral range, which on Linux is typically 32768 to 60999, giving roughly 28,000 ports per destination address and port pair. After the connection closes, the socket stays in TIME_WAIT for 60 seconds on Linux, and that duration is compiled in rather than tunable through sysctl. Opening more than a few hundred short-lived connections per second to the same destination will exhaust the range. The fix is connection reuse through keep-alive and pooling, not raising limits.

Does horizontal scaling always help?

Only for work that is genuinely independent. Adding instances multiplies your capacity for the stateless part of a request while leaving anything shared exactly as it was, so the shared component becomes the ceiling. Worse, the Universal Scalability Law shows that beyond a point, throughput can actually fall as you add capacity, because coordinating the additional workers costs more than they contribute. If each new pod opens more database connections to the same primary, you can absolutely scale out into a slower system.

What is backpressure and why does it matter?

Backpressure is refusing or slowing incoming work when you cannot keep up, rather than accepting it into a queue. Without it, an overloaded system accepts requests it will never serve in time — latency grows until callers time out, retry, and add even more load. A bounded queue that rejects quickly with a 429 and a Retry-After header keeps the system predictable. Unbounded queues do not absorb overload, they conceal it until memory runs out.

Why does latency explode near 100% utilisation?

Because queueing delay is hyperbolic in utilisation, not linear. For a simple queue, response time equals service time divided by one minus utilisation, so the denominator shrinks towards zero as you approach full load. At 50% utilisation you see roughly twice the service time, at 80% five times, at 90% ten times and at 95% twenty times. That is why going from 50% to 80% costs far more latency than the extra 30% of load suggests, and why autoscaling targets set at 80% CPU are already past the useful part of the curve. The exact multipliers assume an M/M/1 queue, but the hyperbolic shape holds for any queue you own.

What is coordinated omission in load testing?

It is a systematic measurement error in closed-loop load generators, named by Gil Tene. Such a generator sends a request, waits for the response, then sends the next one. When the system stalls, every virtual user is blocked waiting, so almost no requests are sent and almost no samples are recorded during the worst period. The resulting p99 is computed over measurements that exclude the stall you care about. Real users arrive on their own schedule and experience the full delay. Fix it by measuring against the intended send schedule, by using a corrected tool such as wrk2 or HdrHistogram's recordValueWithExpectedInterval, or by generating load at a fixed arrival rate regardless of responses.

Can adding servers make a system slower?

Yes, and the Universal Scalability Law describes exactly when. Its formula includes a contention term for queueing on shared resources and a coherency term for the cost of keeping workers consistent with one another. The coherency cost grows with the square of the number of participants, because each one must agree with every other, so beyond a peak at the square root of one minus contention over coherency, throughput actually falls. In practice this is cache-line bouncing, lock handoffs, cluster gossip, consensus rounds and cross-region replication. If each new instance competes for the same shared resource rather than raising its limit, scaling out is a downgrade.

How do I find the bottleneck quickly?

Work outwards from the database. Query pg_stat_activity grouped by wait_event_type to see what sessions are waiting on: many rows in ClientRead mean the database is idle and waiting on your application, while Lock or LWLock waits mean contention inside it, and idle in transaction means your code holds connections without using them. Then check sockets with ss -s and count TIME_WAIT entries, check the ephemeral port range and conntrack counters, and finally check the process file descriptor limit against its current usage. The whole sequence takes a few minutes and identifies the binding constraint far more reliably than adding another dashboard.

What is the USE method?

A checklist from Brendan Gregg for finding bottlenecks fast: for every resource, measure Utilisation, Saturation and Errors. Utilisation alone misleads, because a pool can be 100% utilised and fine, or 60% utilised with a long queue behind it. Saturation — the amount of queued work — is usually the signal that identifies the bottleneck, and it is the metric most teams never export.

Should I use L4 or L7 load balancing?

L4 balances TCP connections and is cheap and protocol-agnostic, but with HTTP/2 or gRPC one connection carries many multiplexed requests, so balancing connections can leave one backend handling most of the actual work. L7 understands requests and can balance them individually, retry idempotent calls, and route by path or header, at the cost of terminating and re-establishing connections. For HTTP/2 and gRPC traffic, request-level balancing is usually what you actually want.

From the community

Discussion on the Fediverse

Replies from Mastodon and Bluesky — straight from the open web, no tracking.

Loading replies …

ENDE