“The dashboard says checkout takes 400 ms. Support says customers are waiting 4 seconds.”
case name: Averages lie — percentiles and histograms
- Service
- shoplite-api · /checkout
- Impact
- 1 in 10 checkouts took 3+ seconds for ~2 hours; some users gave up and retried
- Detected by
- Support tickets: 'the pay button spins forever'
- Time to resolve
- 25 min once the right query was run
Skills you'll use on this case
histogram_quantilesum by (le)average vs p50/p99quantile estimation error00 It starts
Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.
01 The investigation
- 14:10
REPORT
Three support tickets in an hour: 'payment button spins for ages, then works'
Intermittent and eventually successful, the hardest kind of report to act on.
- 14:12
QUERY
Check average latency — the number on the existing dashboard
A histogram's
_sumis the total seconds spent;_countis the number of requests. Dividing their rates gives the MEAN latency.PromQL· Prometheussum(rate(http_request_duration_seconds_sum{job="shoplite", route="/checkout"}[5m])) / sum(rate(http_request_duration_seconds_count{job="shoplite", route="/checkout"}[5m]))result{} 0.412 - 14:14
DEAD END
'412 ms is a bit high but fine — probably the customers' mobile networks'
The average blends 90 fast requests (~110 ms) with 10 very slow ones (~3.1 s) into one number that describes NOBODY's actual experience. No customer waited 412 ms.
- 14:31
QUERY
Look at the distribution instead: median and 99th percentile
histogram_quantileestimates a percentile from bucket counts. The buckets (le= 'less than or equal') must be summed across instances first and KEPT as a label, hencesum by (le).PromQL· Prometheushistogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket{job="shoplite", route="/checkout"}[5m]))) histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job="shoplite", route="/checkout"}[5m])))resultp50 {} 0.108 p99 {} 4.74/checkout latency — mean vs p99 (seconds)meanp99 - 14:33
FINDING
Median 108 ms, p99 4.7 s: this is a slow TAIL, not a slow service
Half the requests are fast. A small fraction is dramatically slow. That changes the search completely: look for something that happens occasionally (a specific query, a lock, a cold cache), not for a service that's slow overall.
- 14:35
QUERY
How many checkouts are over 1 second?
Buckets are cumulative, so the
le="1"bucket counts requests that took at most 1s. One minus its share of the total is the fraction slower than 1s. This number is exact, not estimated.PromQL· Prometheus1 - ( sum(rate(http_request_duration_seconds_bucket{job="shoplite", route="/checkout", le="1"}[5m])) / sum(rate(http_request_duration_seconds_count{job="shoplite", route="/checkout"}[5m])) )result{} 0.101 - 14:52
RESOLVED
A slow report query sharing the table is moved to a read replica
In our lab the slow query is
pg_sleep(3), a stand-in for any occasionally slow statement. Case 0.4 shows how a trace pinpoints exactly which call in the request is slow.
Root cause
10% of checkout requests hit a 3-second database query. The team's only latency number was the MEAN, which diluted the problem to '412 ms, a bit high'. The p99 was 4.7 s and 10% of users waited over a second, but nobody could see that because nobody looked at the distribution.
02 The concepts behind it
Why averages hide the problem
A mean is dominated by the common case and says nothing about the shape of the distribution. 90 requests at 110 ms and 10 at 3.1 s average to 410 ms, a value no real request had. Worse, the mean can stay flat while the tail gets dramatically worse, as long as the fast majority gets slightly faster.
Users experience individual requests, and a user who makes 20 requests per session almost certainly hits the p95. Measure latency as PERCENTILES: p50 for 'typical', p95/p99 for 'bad but common enough to matter'.
How Prometheus histograms work
A histogram is a set of cumulative counters: _bucket{le="0.1"} counts requests that took ≤ 0.1 s, le="0.25" counts those ≤ 0.25 s (including the previous ones), and so on up to le="+Inf", which equals _count. Plus _sum, the total time. Each is a separate time series per label combination, which is why bucket count affects storage cost.
Because buckets are just counters, they can be SUMMED across instances and routes before computing a percentile. That's the big advantage over client-side 'summaries', whose pre-computed quantiles can't be aggregated.
Percentiles from buckets are estimates
histogram_quantile finds which bucket the requested rank falls into, then assumes observations are spread evenly inside it. Our slow requests take ~3.1 s, and all of them land in the (2.5, 5] bucket. The p99 rank is 90% of the way into that bucket's population, so the estimate is 2.5 + 0.9 × 2.5 ≈ 4.75 s, not the true 3.1 s.
The lesson: put bucket BOUNDARIES where decisions happen, such as your SLO thresholds (0.3 s, 1 s), so questions like 'what fraction is under 1 s?' are exact. Newer Prometheus versions also support NATIVE histograms, with automatic high-resolution buckets that remove most of this error; check your version's documentation for how to enable them.
rate() first, then sum by (le), then quantile
The order matters. rate() must be applied to the raw counters (it handles resets per series). sum by (le) then aggregates across instances and other labels while keeping the bucket boundary. histogram_quantile needs le to be present. To get a percentile per route, keep that label too: sum by (le, route).
03 The fix
01Turn the slow query off
In real life this is where you'd find and fix the slow statement. Case 0.4 shows how to find exactly which call is slow using a trace.
terminal$ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"slow-checkout","enabled":false}'── expected output ──{"active":[]}
04 Make sure it never surprises you again
01Put a bucket boundary at your SLO threshold
ShopLite's target is 'checkout under 1 s'.
1is already a boundary, which is why the 'fraction over 1 s' query was exact. Add boundaries where your decisions live, such as0.3for the product page, and remove ones you never use. Each bucket is a separate time series per label combination, so more buckets means more storage.obs-lab/shoplite/server.jsadd to filejs buckets: [0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1, 2, 3, 5, 10],02Chart percentiles per route, never the mean alone
Use this query in the latency panel of the ShopLite dashboard: p99 for every route, estimated from summed buckets.
PromQL· Prometheushistogram_quantile(0.99, sum by (le, route) (rate(http_request_duration_seconds_bucket{job="shoplite"}[5m])))result{route="/checkout"} 0.231 {route="/products"} 0.047
05 Your turn: write the query
Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.
Write a query for the p95 latency of the payments service's /charge route.
Someone writes histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) and gets one result per instance, method, route, and status. Why, and how do you fix it?
With slow-checkout on, what fraction of ALL ShopLite requests (not just checkout) take longer than 1 s?
06 Interview questions from this case
Why should latency be tracked as percentiles rather than averages?
Histogram vs summary in Prometheus: which do you choose?
What does histogram_quantile(0.99, sum by (le) (rate(x_bucket[5m]))) actually compute?