LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Queueing Theory for Capacity Planning: Why Latency Explodes at 80%

capacity-planningperformancedistributed-systemsmathematicskubernetes

Every experienced engineer has seen the pattern: a service runs at 60% CPU and responds in 5 ms, load climbs to 80% and latency creeps to 20 ms, the team adds one more batch job and suddenly p99 is measured in seconds. The on-call engineer scales up, latency drops, and everyone agrees to “watch utilization more closely.” What almost no one does is explain why the degradation happened so suddenly, or derive a principled threshold that could have prevented the incident in the first place.

That explanation comes from queueing theory. The field is a branch of probability theory and operations research developed in the early twentieth century, starting with A.K. Erlang’s work on telephone exchanges. Its core results — Little’s Law, the M/M/1 formula, and the pooling theorem — are among the most practically useful results in all of applied mathematics for systems work. They are not approximations or rules of thumb. They are exact theorems that hold under well-specified assumptions. Understanding when those assumptions hold, and what happens when they break, is most of the skill.

This post derives the key results, explains the hockey-stick latency curve from first principles, and shows how to apply the theory to thread pools, connection pools, Kubernetes autoscaling triggers, and database connection limits.


Little’s Law: The Most Powerful Result in Queueing

Little’s Law states that for any stable system in steady state:

L = λ × W

Where:

  • L is the average number of items in the system (queue + service)
  • λ (lambda) is the average arrival rate (items per second)
  • W is the average time an item spends in the system (seconds)

The remarkable thing about Little’s Law is not the formula — it is the assumptions required to prove it. There are almost none. The result holds for any stable system regardless of the arrival distribution, the service time distribution, the number of servers, or the queueing discipline (FIFO, LIFO, random). The only requirements are that the system is in steady state and that averages exist. John Little proved this in 1961 with almost no distributional assumptions, and the proof is not much harder than an application of the ergodic theorem.

What It Means for Thread Pools

A thread pool is a queueing system. Requests arrive, wait if all threads are busy, get picked up by a thread, and leave after processing. Apply Little’s Law:

  • L = average number of in-flight requests (queue + active)
  • λ = requests per second arriving at the pool
  • W = average response time (end-to-end, not just service time)

If your service handles 500 req/s and average latency is 40 ms (0.04 s), Little’s Law tells you there are 500 × 0.04 = 20 requests in the system on average. If your thread pool has 20 threads, utilization is 100% and the queue is empty on average — but because of variance, you will frequently saturate and build queues. You need more headroom.

More practically: if average concurrency L grows without a corresponding increase in throughput λ, Little’s Law tells you latency is degrading. You do not need to instrument latency directly — concurrency alone is sufficient. This is the basis for Netflix’s gradient-based concurrency limiter and many adaptive load-shedding algorithms.

Connection Pools

A database connection pool follows the same model. The pool has k connections (servers), queries arrive at rate λ, each taking average 1/μ seconds. Average connections in use: λ / μ. If λ / μ > k, queries queue. W = L / λ gives average query latency including wait time. Doubling connections halves wait time under the same load — until the database itself saturates, which is a second queueing stage requiring separate analysis.


Arrival Processes and the Poisson Assumption

The M/M/1 model — the workhorse of queueing theory — assumes that arrivals follow a Poisson process. In a Poisson process, arrivals are independent, and the probability of n arrivals in an interval of length t follows:

P(N(t) = n) = (λt)^n × e^(-λt) / n!

The inter-arrival times are exponentially distributed with mean 1/λ. The Poisson distribution arises naturally when many independent sources each contribute rarely — the classic derivation for telephone calls, HTTP requests from millions of independent users, or packets on a network link. The superposition of many independent low-rate processes converges to Poisson (the law of rare events), which is why it is a reasonable model for aggregate web traffic.

The assumption breaks down in several important scenarios:

Correlated arrivals. If your load is driven by batch jobs, cron tasks, or coordinated retries, arrivals are bursty and correlated. The variance of inter-arrival times is higher than the Poisson model predicts. Real-world service meshes with aggressive retries-on-failure produce the worst-case: a slow response triggers a retry storm, which increases load, which slows responses further. The Poisson assumption dramatically underestimates the latency impact of this pattern.

Self-similar traffic. Network traffic famously exhibits long-range dependence — the Hurst parameter is significantly above 0.5. This means queueing models that assume independent arrivals (Poisson or otherwise) systematically underpredict queue lengths and tail latencies. Fractional Brownian motion models are more appropriate but require different machinery.

Deterministic arrivals. Streaming pipelines reading from a partitioned log like Kafka process records in a pattern closer to deterministic metered arrival than Poisson. For related context on Kafka’s pull-based consumption model, see the Apache Kafka deep dive.

When the Poisson assumption holds badly, the M/M/1 formula gives an optimistic lower bound. Real latency at high utilization is worse than the model predicts. This is a reason to be conservative when setting utilization targets, not a reason to abandon the model entirely.


The M/M/1 Queue: Where the Hockey Stick Comes From

The M/M/1 queue models a single server, Poisson arrivals at rate λ, exponentially distributed service times with rate μ (mean service time 1/μ). The system is stable when ρ = λ/μ < 1, where ρ is utilization — the fraction of time the server is busy.

The exact formula for average time in system (wait + service):

W = 1 / (μ - λ)

Or equivalently, in terms of utilization ρ = λ/μ and mean service time S = 1/μ:

W = S / (1 - ρ)

This is the formula that produces the hockey stick. At low utilization the denominator is close to 1 and W ≈ S — latency is approximately the bare service time. As ρ → 1, the denominator approaches zero and W → ∞. Latency does not merely increase as utilization approaches 100%; it diverges to infinity.

The Hockey Stick, Plotted

The shape of the curve is what matters for capacity planning. With service time S = 1:

Utilization (ρ)  |  Mean latency W = S / (1 - ρ)
-----------------|---------------------------------
      0.10       |         1.11
      0.25       |         1.33
      0.50       |         2.00
      0.60       |         2.50
      0.70       |         3.33
      0.75       |         4.00
      0.80       |         5.00
      0.85       |         6.67
      0.90       |        10.00
      0.95       |        20.00
      0.99       |       100.00
      1.00       |         ∞

The inflection is visible between 70% and 90%. Going from 50% to 75% doubles latency. Going from 75% to 90% triples it again. Going from 90% to 95% doubles it once more. Every additional percent of utilization above 90% costs as much latency as the previous ten.

Rendered as ASCII:

Latency
  |
  |                                                *
  |                                              *
  |                                           *
  |                                        *
  |                                    *
  |                              *
  |                       *
  |               *
  |       *  *  *
  |  *  *
  +--+--+--+--+--+--+--+--+--+--+--> Utilization
  0  10 20 30 40 50 60 70 80 90 100%

  Target zone: ≤70%      Caution: 70-85%      Danger: 85%+

The inflection is not an artifact of the exponential service-time assumption. It appears in M/G/1 (general service times), M/D/1 (deterministic service), and real measured systems. The exponential assumption affects the exact multiplier but not the qualitative shape. This universality is why the M/M/1 formula, despite its simplifying assumptions, gives practitioners a reliable mental model.


Why p99 Latency Is a Queueing Phenomenon

Average latency W is only part of the story. In a production service, the p99 or p99.9 latency is often the number that matters — it determines user-facing timeout rates, SLA compliance, and cascading failure risk when this service is in the call graph of another service.

The tail of the latency distribution is driven primarily by queueing, not by service time variance. In an M/M/1 queue, the number of customers in system L follows a geometric distribution:

P(L = n) = (1 - ρ) × ρ^n

This is a heavy-tailed distribution. When utilization is high, the probability that n customers are in the system decreases slowly with n. A request arriving when 20 others are already waiting must wait for all 20 to complete before service begins. These rare but non-negligible long queues produce the long tail in latency distributions.

The practical consequence: p99 latency scales far more aggressively with utilization than the mean. At 80% utilization, roughly 1% of arrivals find more than log(0.01) / log(0.8) ≈ 21 customers ahead of them; each requires on average 1/μ of service time, so p99 wait is approximately 21 × S before your own service begins. At 90% utilization the same calculation gives roughly 43 × S. Mean latency is a poor leading indicator of user experience degradation; p99 is the number to alert on, and histogram-based metrics (Prometheus histogram_quantile) are preferred over summary quantiles precisely because they aggregate correctly across replicas.


Multiple Servers: M/M/k and the Pooling Theorem

Real systems have multiple workers: a thread pool with k threads, k database connections, k Kubernetes pod replicas. The M/M/k model extends M/M/1 to k identical servers sharing a single queue. The utilization per server is:

ρ = λ / (k × μ)

The mean wait time in queue (not including service) requires the Erlang-C formula:

C(k, ρ) = [ (kρ)^k / (k! × (1 - ρ)) ] / [ Σ_{n=0}^{k-1} (kρ)^n/n! + (kρ)^k / (k! × (1 - ρ)) ]

W_q = C(k, ρ) / (k × μ × (1 - ρ))
W   = W_q + 1/μ

The Erlang-C formula is harder to evaluate by hand but has a critical property: for fixed total capacity (k × μ = constant), a single pool of k servers with one shared queue always outperforms k independent single-server queues. This is the pooling theorem, sometimes called the supermarket-versus-bank-checkout insight.

Supermarket vs. Bank Checkout

Consider two layouts:

Configuration Queues Servers Mean wait
k dedicated lanes (supermarket-old) k 1 per lane W_old = S / (1 - ρ) per lane
1 pooled queue (modern supermarket / bank) 1 k shared W_pooled < W_old / k in many regimes

With dedicated lanes, a customer who joins a slow lane is stuck even if adjacent lanes are empty. With a pooled queue, the first available server takes the next customer, eliminating this wasted capacity. The variance in queue length is also lower in the pooled configuration, which reduces tail latencies.

The magnitude of the advantage is load-dependent. At low utilization (ρ < 0.3) the difference is small — everyone gets served quickly either way. At high utilization (ρ > 0.7) the pooled queue reduces mean wait time by a factor that grows approximately as k^0.5 for large k, and the tail-latency reduction is even more dramatic.

Operational implication: never partition your thread pool or connection pool by request type unless you have a compelling reason (isolation of high-priority traffic). A single pool shared across all request classes will outperform segmented pools of the same total size. This is why Netty, Tomcat, gRPC, and nearly every serious server framework use a single shared worker pool rather than per-endpoint pools.


Practical Application: Thread Pools, Connection Pools, Kubernetes HPA

Thread Pool Sizing

The classic formula for CPU-bound work:

k_threads = N_cores × (1 + wait_ratio)

where wait_ratio = time_waiting_for_IO / time_computing. For pure CPU work this gives N_cores. For a service that spends 50% of its time waiting on downstream calls (wait_ratio = 1), the formula gives 2 × N_cores. These are starting points; the M/M/1 analysis tells you that your target utilization for the pool should be at most 70% to keep latency near the bare service time.

With N_cores = 8 and a wait_ratio of 1, a thread pool of 16 gives theoretical maximum throughput. But running at 90% utilization means W = S / 0.1 = 10 × S — ten times the bare service time. Running at 70% means W = S / 0.3 = 3.3 × S. For most latency-sensitive services, the 70% cap is the right operating point.

In Java, virtual threads (Project Loom, JDK 21+) change the calculus for IO-bound work — they are cheap enough to create one per request without pool sizing concerns. The queueing dynamics at the downstream service still apply; the bottleneck shifts but does not disappear.

Connection Pool Sizing

For a database with a maximum of C connections, each holding a transaction for average S seconds, maximum throughput is C / S transactions per second. Every millisecond of unnecessary latency in a database transaction directly reduces the maximum throughput you can extract from a fixed pool.

The practical sizing formula:

pool_size = (target_tps × avg_transaction_ms) / 1000 / target_utilization

For a service targeting 500 TPS, 20 ms average transaction time, and 70% utilization:

pool_size = (500 × 20) / 1000 / 0.70 ≈ 14.3 → 15 connections

At 15 connections and 70% utilization, the system has meaningful headroom. A sudden burst to 700 TPS drives utilization to ~93%, and the M/M/1 formula predicts query latency (wait + service) rising to approximately 20 ms / (1 - 0.93) ≈ 286 ms — a 14x jump that will likely trigger application-level timeouts. This is why traffic spikes feel much more severe at the database than at the application tier.

HikariCP, the standard Java connection pool, exposes these parameters directly:

1
2
3
4
5
6
7
datasource:
  hikari:
    maximum-pool-size: 15
    minimum-idle: 5
    connection-timeout: 3000    # ms — time to wait for a connection from pool
    idle-timeout: 600000        # ms — remove idle connections after 10 min
    max-lifetime: 1800000       # ms — recycle connections after 30 min

The connection-timeout is the load-shedding valve. When the pool is exhausted and a request waits longer than that limit, HikariCP throws a SQLTimeoutException — fast failure that avoids holding thread pool slots on requests that would time out at the caller regardless.

Kubernetes HPA Configuration

Kubernetes Horizontal Pod Autoscaler should not be triggered at 80% CPU. By the time CPU hits 80% in a latency-sensitive service, the M/M/1 model predicts latency is already the baseline, and the time to provision and warm a new pod (typically 30–90 seconds) means users have experienced degraded service for minutes before the new capacity is available.

A better strategy: trigger scale-out at 60–65% CPU, and configure custom metrics based on request queue depth or p99 latency directly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-service
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60    # Scale at 60%, not 80%
    - type: Pods
      pods:
        metric:
          name: http_request_queue_depth
        target:
          type: AverageValue
          averageValue: "5"         # Scale when avg queue depth exceeds 5
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30    # Aggressive scale-up
      policies:
        - type: Percent
          value: 100
          periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 300   # Conservative scale-down

The asymmetric behavior is intentional: aggressive scale-up costs minor over-provisioning; aggressive scale-down risks hitting the nonlinear region and triggering a latency spike that takes minutes to recover.

For Spark-based batch workloads — where throughput matters more than latency and the cost of over-provisioning is high — a higher target utilization of 80–85% is defensible. The trade-off analysis for those workloads appears in the discussion of executor sizing in the Apache Spark fundamentals post.


Queue Comparison: System Types and Their Properties

Queue model Servers Arrival Service Best used for
M/M/1 1 Poisson Exponential Single-threaded worker, serial DB
M/M/k k (shared queue) Poisson Exponential Thread pool, connection pool
M/D/1 1 Poisson Deterministic Fixed-size packet processing
M/G/1 1 Poisson General CPU-bound work (variable compute)
G/G/1 1 General General Bursty arrivals, heavy-tailed service
M/M/k/k k (no queue) Poisson Exponential Erlang-B: circuit capacity (hard reject)

The M/G/1 queue is particularly useful for CPU-bound services with high variance in compute time (e.g., a query engine where some queries are trivial and others involve full table scans). The Pollaczek-Khinchine formula for M/G/1 gives:

W_q = (ρ × S × (1 + C_s^2)) / (2 × (1 - ρ))

where C_s^2 = σ_s^2 / S^2 is the coefficient of variation squared for service times. For exponential service (C_s^2 = 1) this reduces to the M/M/1 formula. For deterministic service (C_s^2 = 0) the wait is halved. For high-variance service (C_s^2 > 1, as in a query engine with occasional slow queries) the wait grows proportionally with variance. This is why limiting the maximum service time of any single request — through timeouts, circuit breakers, or query cost limits — reduces tail latency for all requests sharing the pool.


Back-Pressure and Flow Control in Streaming Systems

The connection between queueing theory and back-pressure is direct. A streaming pipeline is a series of M/M/k stages, each with its own arrival rate, service rate, and utilization. If any stage is saturated (ρ ≥ 1), its queue grows without bound and the pipeline accumulates lag. Back-pressure propagates this saturation signal upstream: slow down your producer, because the downstream stage cannot keep up.

Without back-pressure, the upstream stage continues producing at full rate, the intermediate queue grows until memory is exhausted, and the system either crashes or begins dropping records. This is the “dead letter queue filling up” pattern that indicates a missing or broken back-pressure mechanism.

In Kafka-based streaming systems (see the Apache Kafka deep dive), consumer lag is the direct observable for queueing depth at the Kafka-to-consumer stage. The Kafka consumer itself applies implicit back-pressure: if the consumer thread is busy processing a batch, it does not fetch the next batch. The throughput ceiling for a single consumer is k_threads × (1/S) — Little’s Law applied to the consumer processing pool. Scaling consumers (adding partitions, adding consumer instances) increases k and raises the throughput ceiling linearly, until the next bottleneck stage — typically a downstream database write — saturates.

Reactive streams specifications (Java Flow API, Project Reactor, RxJava) formalize this as an explicit demand signal: a subscriber declares how many items it is ready to receive, and the publisher does not emit beyond that count. This is the queueing model applied in reverse — instead of measuring queue depth after the fact, the system bounds it prospectively.


Practical Rules for Utilization Targets

Synthesizing the theory into operational guidance:

System type Target utilization Rationale
Latency-sensitive API (p99 < 50 ms target) ≤ 60% Buffer for bursts; scale-out lead time; keep W < 2.5 × S
General API service (p99 < 500 ms target) ≤ 70% Standard operating point; W = 3.3 × S
Background job processing ≤ 80% Throughput-oriented; latency tolerance is higher
Batch / analytics (Spark, etc.) ≤ 85% Cost-efficiency dominant; short queues acceptable
Database CPU ≤ 60% Database is shared; queueing amplifies across all callers
Database connection pool ≤ 70% Fast failure preferred over queuing; timeout economics
Kubernetes HPA trigger (CPU) 60% Scale-out before the nonlinear region is reached
Network links ≤ 70% Burst absorption; TCP retransmit amplifies queueing

These are not arbitrary percentages — they are read off the M/M/1 latency table. At 70% utilization, mean latency is 3.3 × S. At 80%, it is 5 × S. The threshold between “manageable” and “problematic” sits around 70–75% for most latency targets, which is why the “80% rule” that circulates in operations lore is approximately correct but slightly too permissive for low-latency services.

The other rule: always pool queues. If you find yourself with per-endpoint thread pools, per-tenant connection pools, or per-priority request lanes, you are fragmenting capacity and operating each fragment at higher effective utilization than the aggregate. The pooling theorem guarantees that consolidation improves both mean latency and tail latency for fixed total resources.


Verdict

Queueing theory is not an academic curiosity. The M/M/1 formula W = S / (1 - ρ) and Little’s Law L = λW are the two equations that explain most production latency incidents. The hockey-stick shape of the utilization-latency curve is exact under the M/M/1 assumptions and qualitatively correct under every reasonable relaxation of those assumptions. The divergence of latency as utilization approaches 100% is not a failure mode — it is the fundamental mathematics of resource contention.

The engineering response is simple: keep latency-sensitive systems below 70% utilization, pool resources rather than fragmenting them, trigger autoscaling before the nonlinear region, and instrument concurrency (L) and throughput (λ) to detect latency degradation via Little’s Law before it is visible in direct latency measurements. These rules are not cargo-culted best practices — they follow from the same theorems that Erlang derived for telephone networks more than a century ago, which have survived every generation of distributed systems without modification.


Sources

  • Little, J.D.C. “A Proof for the Queuing Formula: L = λW.” Operations Research, 9(3), 1961. The original proof with minimal distributional assumptions.
  • Kleinrock, Leonard. Queueing Systems, Volume 1: Theory. Wiley-Interscience, 1975. Canonical graduate reference; Chapter 2 covers M/M/1 and M/M/k in full.
  • Gross, Donald, et al. Fundamentals of Queueing Theory, 4th ed. Wiley, 2008. Accessible coverage of Erlang-C and the pooling theorem.
  • Gregg, Brendan. Systems Performance: Enterprise and the Cloud, 2nd ed. Addison-Wesley, 2020. Chapter 2 applies queueing theory to OS and storage performance.
  • Gunther, Neil J. Analyzing Computer System Performance with Perl::PDQ. Springer, 2005. Practical derivation of the Pollaczek-Khinchine formula.
  • HikariCP configuration reference. https://github.com/brettwooldridge/HikariCP#gear-configuration-knobs-baby

Comments