Tail Latency¶
Prerequisites: Caching & Performance, Capacity Estimation
← Cache Strategies | Next: Cache Stampede →
Why This Exists¶
Dashboard: CPU 40%, avg 95ms, error rate 0.1%. Slack is on fire. Checkout "takes forever."
You open percentiles:
p50 = 80ms ← the average's friend; most users are fine
p95 = 180ms ← still inside the 200ms SLO
p99 = 6s ← 1 in 100 checkouts is a lost session
A 1k rps service produces 10 requests/s that wait 6 seconds. That is 36,000 terrible checkouts per hour. Averages hid an outage because they are pulled by the 99, not named by them.
If 2% of users abandon at 3s, that tail is not a graph — it is revenue. The SLO belongs on p99, not on mean.
Mental Model
Latency is a distribution, not a number. The tail is where queues, locks, GC, and the slowest dependency live. Users and SLOs live in the tail. The mean lives in a slide deck.
p50 = typical · p99 = product · max = somebody's lawsuit
Naive System → What Breaks¶
You SLO on average latency and scale when CPU > 70%.
| Reality | Why the average lied |
|---|---|
| 1% of calls hit a cold disk / slow neighbor | 99 × 80ms + 1 × 6s = 139ms avg — "fine" |
| One slow query on a shared connection | Everyone behind it waits — HOL blocking |
| Thread pool 50, in-flight 80 | Queue time ≫ service time; CPU still 40% |
| Fan-out to 20 microservices | p99_total ≈ 1 − (0.99)^20 ≈ 18% of requests see some p99 |
| GC every 30s for 400ms | A slice of users get +400ms; avg barely moves |
The Concept¶
Percentile pN = the latency that N% of requests beat. p99 = 99% faster than this; 1% slower.
Why tails form:
- Queueing — when utilization ρ → 1, wait time explodes (not linearly).
- Little's Law —
L = λW(in-flight = arrival rate × time in system). Slow W inflates L; finite pools then inflate W. Spiral. - Head-of-line (HOL) blocking — one fat/slow request occupies a worker, connection, or HTTP/1.1 socket; the thin requests behind it inherit its wait.
- Pool exhaustion — DB pool, GC threads, connection pool: waiters sit in a queue the CPU dashboard cannot see.
- Fan-out — a page that calls N backends inherits the max of their tails.
The 40% CPU, 6s p99 pattern: you are not compute-bound. You are concurrency-bound. The missing 60% CPU is threads stuck on a lock, a disk, or a 1% dependency.
Architecture¶
flowchart LR
REQ[Requests λ] --> Q[Run queue]
Q --> W[Workers / pool]
W --> DEP[Deps + disk + lock]
DEP -->|slow 1%| TAIL[p99 / p999]
W -->|fast 99%| OK[p50]
Q -.->|HOL| TAIL
style TAIL fill:#b71c1c,color:#fff
style OK fill:#2e7d32,color:#fff
style Q fill:#e65100,color:#fff Mechanics¶
Queueing in one line¶
For an M/M/1-ish server, E[wait] ≈ ρ / (1−ρ) × S. At 50% busy, wait ≈ service. At 80%, wait = 4×. At 95%, wait = 19×. p99 is much worse than the mean wait. Headroom is a latency feature.
Little's Law, used on-call¶
λ = 1,000 rps W = 80ms = 0.08s L ≈ 80 in-flight — healthy
λ = 1,000 rps W = 6s L ≈ 6,000 — you do not have 6,000 workers
If the pool is 200, 5,800 requests sit in an accept/queue. Their latency is queue time, not handler time. CPU can be 40% (200 workers blocked on I/O).
HOL blocking¶
HTTP/1.1 one-connection-per-client, a single JDBC connection reused serially, a single-threaded event loop doing a 200ms JSON parse, a Kafka partition whose consumer hits a poison 10s call — all HOL. The fix is isolation: HTTP/2 multiplexing (with care), separate pools for fast/slow, hedged requests, or cancel.
Slow 1% dependency¶
page = auth + recs + ads + inventory + price
each p99 = 200ms independently
P(page > 200ms) ≈ 1 − 0.99^5 ≈ 5%
You are already at p95 of the page from five "p99=200ms" toys. This is why edge SLOs cannot equal the worst backend SLO.
Hedge / tail chopping (use sparingly)¶
If p99 is 10× p50, a hedged retry after p95 wait can cut tail — and double load on the tail-causing dep. Pair with retry budgets.
Coordinated omission¶
A closed-loop load test waits for each response before sending the next. When the system slows, the generator slows — p99 looks better than production, because the waits never entered the sample. Open-loop generators (constant λ) and measuring intended start time expose the real tail. If your load test p99 is 180ms and prod p99 is 6s at the same CPU, believe prod and suspect the test.
What to chart together¶
Always plot p50 / p95 / p99 / p99.9 + avg on one graph, plus in_flight, pool_wait_ms, and CPU. The signature avg≈p50≪p99, CPU mid, pool_wait≈p99 is this entire page in one screenshot.
Realistic Example With Numbers¶
Checkout API: 800 rps, 32 Java workers, DB pool 20, CPU 40%.
p50 80ms handler 50ms + DB 30ms
p95 180ms cache miss + lock wait
p99 6s DB pool wait 5.8s + 200ms query
avg 140ms looks "a bit slow"
What happened: a new query on orders full-scans for 1% of users (big accounts). Those hold a pool connection 2s. 800 rps × 1% = 8 slow/s. 8 × 2s = 16 extra connections — almost the whole pool of 20. Everyone else queues. The 99% pay for the 1%.
Fix order: index / kill the query (cause), then timeout the query at 100ms (bound), then split pools (isolate), then more connections (last — they amplify lock contention).
Interactive Explainer¶
Run a healthy stream, then inject HOL blocking or a 1% slow dependency. Watch avg stay polite while p99 detaches.
Failure Modes¶
| Mode | p50 | p99 | CPU | Tell |
|---|---|---|---|---|
| Slow 1% query | OK | Seconds | Low–mid | One query in traces |
| HOL on pool | Rises a bit | Seconds | Low | pool_wait ≈ p99 |
| GC thrash | OK | 200–800ms periodic | Spikes | pause histogram |
| Lock convoy | OK / rising | Huge | One core hot | pg_locks / Java lock profile |
| Saturated queue | All rise | Huge | High | ρ → 1; need capacity |
| Fan-out | OK | Inherits worst dep | Low | trace the max child |
| Noisy neighbor | Flaky | Flaky | Fine | only some pods |
Production Trap
Scaling out replicas because p99 is high and CPU is 40% gives you more queues waiting on the same hot row or the same 1% query. You have scaled the waiters. Fix the hold time.
Production Debugging¶
CPU 40% + p99 6s not a scale-out; find blockers
Memory swap / GC old-gen pauses become p99
Disk fsync / read amp one slow volume poisons a shard
Network retrans, DNS 1% of calls take 5s (nscd / IPv6)
Queue depth run queue, LB, Kafka wait time is latency you own
Lag consumer / replica users read stale *and* wait
Pools db, http, threads active=max, wait_ms ≈ p99 → smoking gun
p50/p95/p99 always chart together if p99/p50 > 10, you have a tail problem
Error rate may be *low* timeouts not counted? they are the tail
Timeouts client 3s, you 30s user sees 3s; you still hold the pool
Retries amplify the 1% 1% slow → 3% load on the slow path
GC match pause to p99 if equal, the tail *is* the collector
Locks one key / one row celebrity user, global mutex, flights table
Trace one p99 request. If 5.8s is pool.acquire, you are done hunting the handler.
Scaling Limits¶
- You cannot SLO p100. Hardware, GC, and the kernel have a max. SLO p99 or p99.9 and budget error + timeout.
- Fan-out of 50 serial deps: p99 becomes fiction. Collapse deps, parallelize with a deadline, or cache.
- Coordinated omission: if the load generator waits for a response before sending the next, it understates p99. Use a closed vs open model on purpose.
- Multi-tenant: one tenant's 1% is everyone's p99 on a shared pool — isolate or you will "optimize" the wrong customer.
- p99 of a 10 rps service is statistically noisy (1 sample / 10s). Collect longer windows or use p95.
Trade-offs¶
| Dimension | Optimize p50 | Bound p99 | Over-provision ρ≪0.5 |
|---|---|---|---|
| Latency | Great typical | Predictable tail | All percentiles drop |
| Throughput | High utilization | Some shed / timeout | Wasted boxes |
| Availability | Hidden outages | Timeouts as errors | Quiet |
| Consistency | — | Cancelled writes | — |
| Durability | — | — | — |
| Complexity | Low | Hedging, pools, traces | Low |
| Cost | Cheap until the tweet | Eng time | Cloud bill |
| Ops | Mean dashboards | Percentile + traces | "Just buy more" |
Interview Questions¶
Q: p50 is 80ms, p99 is 6s, CPU 40%. What is going on?
"The average and the CPU say we are not compute-bound. One in a hundred requests is waiting on something off-CPU: a queue, a lock, a pool, GC, or a slow dependency. I would look at pool wait, traces of the slow requests, and whether a 1% path (large tenant, cache miss, full scan) is holding a shared resource and HOL-blocking the rest."
Q: A page fans out to 20 backends, each p99=100ms. What is the page p99?
"If the calls are in parallel and we wait for all, page latency is the max of 20, so P(page ≤ 100ms) ≈ 0.99^20 ≈ 82%. That is roughly p82, not p99 — about 18% of pages see at least one backend's tail. Serial would be worse (sum). Fixes: cut fan-out, deadline each child, stale-while-revalidate the optional ones, cache, or hedge with a budget. I would not give the page a 100ms SLO."
Q: We auto-scale on CPU. p99 melts every day at noon. Design the SLO and the scale policy.
"CPU autoscaling cannot see pool wait. I'd SLO on p99 and on pool_wait_ms / in-flight vs limit. Scale on saturation: queue depth, not utilization. Concurrently I want a concurrency limiter (load shed) so L cannot walk to 6,000. Then a working group on the noon query — usually a report, a cron, or a partner API. Org: product sees p99, not avg, in the weekly review, or we will keep shipping features that tax the 1%. Cost: I will buy 2× headroom on the shared pool long before I buy 2× CPU."
Reasoning Exercises¶
- 500 rps, mean service 10ms, one worker. At what ρ does mean wait exceed 50ms (M/M/1)? What happens to p99 sooner than that?
- DB pool 10. 2% of queries take 1s, the rest 10ms, 200 rps. Estimate connections held by the slow class. Who queues?
- You add 3 retries with a 2s timeout to "fix" p99. What happens to L and to the dependency? Sketch the worse p99.
- Two tenants share a pool. Tenant A is 1% of QPS and 90% of hold time. Propose isolation that does not require a rewrite.
Key Takeaways¶
Remember
- Averages hide outages; always publish p50/p95/p99 together.
- CPU 40% + p99 seconds = queues, locks, pools, or a 1% dep — not "need more cores."
- Little's Law: slow W fills the pool; a full pool makes W worse.
- HOL: one slow occupant taxes everyone behind it.
- Fan-out multiplies tails; page SLO ≠ backend SLO.
Previous: Cache Strategies | Next: Cache Stampede
Staff Engineer Lens
Tail latency is a product metric wearing an ops costume. Staff engineers put p99 (and the trace of a p99) in the same review as conversion. They refuse mean-only SLAs with vendors. They treat pool wait as a first-class SLO because it is the earliest honest signal that Little's Law is about to collect.
Interview Insight 🎯
The "p50=80ms p99=6s CPU 40%" prompt is a filter. Name queueing + shared pool + 1% slow holder. If you say "add caches and more pods" first, you missed it. If you say "trace a p99, check pool_wait, isolate the slow class," you are senior.