Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Capacity Planning: Finding the Knee Before Production Does

Finding the point where latency turns vertical, applying Little law to size pools and predict queueing, choosing headroom for failover and spikes, and running load tests that produce honest numbers.

Intermediate7 min readUpdated
On this page

Capacity planning has a reputation for spreadsheets, and the useful version is much smaller: find the load at which latency starts bending upward, then run comfortably below it. Everything else is arithmetic.

Key Takeaways

  • The number that matters is the knee — where latency starts rising steeply — not maximum throughput.
  • Little's Law (L = λ × W) sizes pools and predicts when a queue will grow without bound.
  • Use an open-model load generator, or coordinated omission will flatter your tail latency.
  • Plan headroom for the largest single failure: three zones means running near 60%.
  • Test degradation, not just capacity — what the service does past the knee matters more than where the knee is.

The curve

Throughput plateaus while latency goes vertical. Past saturation, throughput often decreases, because the system spends its time on work that has already timed out.

Every queueing system has this shape. Below the knee, an arriving request finds a free worker immediately. Above it, requests queue, and queue time is added to every subsequent request — so latency does not rise linearly with load, it rises hyperbolically.

The collapse region is the one people underestimate: past saturation, throughput actively falls, because the system is doing work for clients that have already given up, and because the queue itself consumes memory and CPU.

Find the knee by ramping load and watching p99, not average:

k6 — an open model, ramping
export const options = {
  scenarios: {
    ramp: {
      executor: 'ramping-arrival-rate',      // arrival rate, NOT virtual users
      startRate: 50, timeUnit: '1s',
      preAllocatedVUs: 100, maxVUs: 2000,
      stages: [
        { target: 100, duration: '2m' },
        { target: 200, duration: '2m' },
        { target: 400, duration: '2m' },
        { target: 800, duration: '2m' },
      ],
    },
  },
  thresholds: { http_req_duration: ['p(99)<1000'] },
};

ramping-arrival-rate keeps to a schedule regardless of how the system responds. A closed model — fixed virtual users each waiting for a response — slows its own request rate when the system slows, which hides exactly the behaviour you are trying to measure. That is coordinated omission, explained in Latency spikes.

Watch for k6's dropped_iterations metric. If it is non-zero, the generator could not keep the schedule and the results understate the tail again.

Little's Law

L = lambda x W
L      = average number of requests in the system
lambda = arrival rate (requests per second)
W      = average time in the system (seconds)

Three ways it earns its keep:

Sizing a pool. 500 req/s with 40ms average service time needs 500 × 0.040 = 20 concurrent workers just to keep up, with zero margin. Size for 30–40 and you have headroom.

Predicting a queue. If concurrency is capped at 10 and λ × W says you need 20, the other 10 requests per unit time must queue — and the queue grows without bound until something rejects.

Deriving the maximum. With 20 workers and a 40ms service time, maximum throughput is 20 / 0.040 = 500 req/s. Anything above that queues by definition.

worked example — where is the bottleneck?
Target:              1,000 req/s
Per request:         5ms CPU + 45ms database wait = 50ms
Concurrency needed:  1000 x 0.050 = 50 in flight
 
Request threads:     200      OK
DB connections:      15       <- the constraint
 
Max via the pool:    15 / 0.045 = 333 req/s
 
At 1,000 req/s arriving, 667 req/s worth of requests queue for a connection,
and the queue grows forever.

That arithmetic locates the bottleneck before any load test runs, and it is exactly the reasoning behind connection-pool exhaustion. The answer here is not more threads — it is a larger connection pool (up to what the database can take), a faster query, or fewer database calls per request.

Headroom

a three-zone deployment
Measured knee:            450 req/s per instance
Peak traffic:             3,000 req/s
Instances needed at knee: 7
 
Zone failure: lose 1 of 3 zones, remaining instances take +50%
  -> size for 3000 x 1.5 = 4,500 req/s -> 10 instances
Growth to the next review (20%)          -> 12 instances
Scaling lag: 90s pod startup vs a 10s spike -> keep 2 warm spare -> 14

The reasoning is more useful than the number. Ask: what is the largest single failure this must survive, how fast can traffic actually arrive, and how long does adding capacity take? A service that takes ninety seconds to start cannot autoscale in response to a spike that arrives in ten — it needs standing headroom instead.

Target utilisation follows from the failure domain: two zones means running near 50%, three near 66%, five near 80%. Running at 90% with any redundancy requirement is arithmetic that does not work.

What to measure

MetricWhy
p50, p95, p99, p99.9The tail is the user experience
Throughput at each load levelWhere the plateau is
Error rate by typeTimeouts and rejections mean different things
CPU, and CPU throttlingThrottling invalidates everything else
GC pause total and p99Whether GC is the constraint
Pool saturation (threads, connections)Which resource runs out first
Queue depthThe leading indicator

The last three answer the question a load test should answer: which resource is the bottleneck? Knowing the knee is 450 req/s is less useful than knowing that at 450 req/s the connection pool saturates, because the second tells you what to change.

Test degradation, not just capacity

what happens past the knee
export const options = {
  scenarios: {
    beyond_capacity: {
      executor: 'constant-arrival-rate',
      rate: 900, timeUnit: '1s',       // deliberately 2x the knee
      duration: '10m',
      preAllocatedVUs: 500, maxVUs: 3000,
    },
  },
};

The questions this answers are the ones that matter in an incident:

  • Does it return fast 503s, or does latency grow without limit?
  • Does memory stay flat, or does an unbounded queue fill the heap?
  • Does it recover when load returns to normal, or stay degraded?
  • Do circuit breakers open, and do they close again?

A service that fails cleanly at 900 req/s is far better than one that reaches 950 and then falls over and stays down. That property — graceful degradation — is worth testing explicitly, and it is the thing that distinguishes the defences in Cascading failure from theory.

Making the test honest

Realistic data volumes. A table with a thousand rows uses different query plans from one with ten million. Load-test against production-shaped data or the results are fiction.

Realistic key distribution. Uniformly random keys give a 100% cache miss rate; a Zipfian distribution — a few very popular items — matches real traffic and exercises hot-key behaviour.

Warm up first. The JIT needs thousands of invocations. Discard the first two minutes.

Include the network. Testing from the same host removes real latency and TLS handshake cost.

Test the whole system. A single service in isolation says nothing about what happens when three services contend for the same database.

What gets asked

"How would you size this service?" or "how do you know it can handle Black Friday?" The strong answer is a method: find the knee with an open-model ramp, use Little's Law to identify which resource saturates first, then add headroom for the largest single failure and for scaling lag.

The detail that separates candidates is testing beyond the knee. Most people size for the target load; far fewer verify what the service does when the target is exceeded — which is the situation the plan exists for.

Frequently Asked Questions

What is Little law and how do I use it?
L equals lambda times W: the average number of requests in the system equals the arrival rate multiplied by the average time each spends there. Rearranged, it sizes pools — at 500 requests per second with an average of 40ms each, you need at least twenty concurrent workers to keep up. It also predicts queueing: if concurrency is capped below lambda times W, the excess must queue, and the queue grows without bound.
How much headroom should a service have?
Enough to absorb the largest single failure plus normal variance. In a three-zone deployment, losing one zone means the other two take fifty percent more traffic, so peak utilisation should stay near sixty percent. Add margin for traffic growth between capacity reviews and for the fact that scaling up takes time — a pod that needs ninety seconds to warm up cannot respond to a spike that arrives in ten.
Why is finding the knee more useful than finding the maximum throughput?
Because the maximum is a point you must never operate near. Just below saturation, small increases in load cause large increases in latency, so a service running at ninety-five percent of maximum throughput has terrible and unpredictable tail latency. The knee — where the latency curve starts bending upward — is the real capacity, and it is usually well below peak throughput.

Related tutorials