Average latency hides tail pain. Learn when mean lies, how p50/p95/p99 work, why averaging quantiles fails, and how Prometheus histograms fix aggregation.
11 min read
p50 is the median latency (half of requests were faster), p95 is the point that 95% of requests beat, leaving 5% slower, and p99 leaves only the slowest 1%, so on high traffic it still covers many real users. In a 10,000-request histogram lab, the mean was 57.3 ms and p95 was 42.0 ms while p99 was 1,156.9 ms, a tail that would breach a 300 ms p99 SLO even though the average graph looks calm.
Both can be true. The mean is a single number. User pain lives in the shape of the latency distribution — especially the tail.
This is a diagnostic playbook for that lie:
Those graph posts stay the place for golden-signal literacy. This post zooms into percentiles vs mean, histograms, and wrong aggregation. Link them; do not merge content.
Internal reading:
Lab honesty: Critical mean / percentile / avg-of-p99 tables below are from a local histogram lab (not Grafana) run on 29 Sep 2026 IST — see the tables in this post. Docker/Prometheus was unavailable on the lab box, so numbers are seeded numpy samples + classic histogram_quantile interpolation (not a live Prom scrape). Grafana screenshots and a native-histogram production scrape are optional follow-ups.
Suppose 100 requests in a window:
| Count | Latency |
|---|---|
| 95 | 20 ms |
| 4 | 100 ms |
| 1 | 5 000 ms |
If your alert is “mean > 100 ms,” you sleep. If your SLO is “p99 < 300 ms,” you page. Same data; different truth.
Method: local histogram lab (not Grafana). Seed 20260929. Pod A healthy n=8000; pod B sick canary n=2000; merge = concatenate samples. Exact percentiles via numpy; also classic Prom-style histogram_quantile on buckets le ∈ {5ms…10s,+Inf}. Commands and raw outputs are archived in ShopperCove lab notes for this post.
| Series | n | mean | p50 | p95 | p99 |
|---|---|---|---|---|---|
| Pod A (healthy) | 8 000 | 26.4 ms | 20.1 ms | 36.8 ms | 117.5 ms |
| Pod B (sick canary) | 2 000 | 180.9 ms | 22.9 ms | 286.8 ms | 4389.6 ms |
| Merged service (exact) | 10 000 | 57.3 ms | 20.6 ms | 42.0 ms | 1156.9 ms |
Merged histogram_quantile | 10 000 | — | 20.3 ms | 49.2 ms | 1235.3 ms |
Mean 57 ms and p95 42 ms look calm. Merged p99 ≈ 1.16 s (histogram estimate ≈ 1.24 s) would breach a 300 ms p99 SLO. That is the optimistic-mean lie with real numbers.
The mean is still useful for capacity and cost (total work ≈ rate × mean). It is a weak user-experience SLI when the distribution is skewed — and latency almost always is.
| Lie | What the graph shows | What users feel |
|---|---|---|
| Optimistic mean | Low average; rare disasters diluted | Tail users furious |
| Pessimistic mean | One pathological spike pulls the average up | Most users fine; you over-react |
Percentiles do not remove judgment — they make the judgment match the question (“how bad is it for the slowest X%?”).
Prometheus talks about φ-quantiles where 0 ≤ φ ≤ 1 (0.95 = p95). See Histograms and summaries.
A percentile is meaningless without:
Changing any of the three changes the number. Dashboards that hide the window in a tiny legend cause false fights in postmortems.
This is the mistake that makes multi-replica services look artificially healthy or weirdly unstable.
avg(p99) is statistically nonsenseIf pod A’s p99 is 50 ms and pod B’s p99 is 500 ms, (50+500)/2 = 275 ms is not “the service p99.” You threw away the distributions. Prometheus’s own docs call out that aggregating precomputed summary quantiles “rarely makes sense,” and that averaging them “yields statistically nonsensical values.”
Bad:
Good (histogram — native):
Good (classic histogram — note le):
Source: Prometheus — Histograms and summaries.
Same dataset as the table above (Pod A + Pod B):
| Aggregation | Resulting “p99” | vs true merged exact p99 (1156.9 ms) |
|---|---|---|
Unweighted avg(podA_p99, podB_p99) | 2253.6 ms | +1096.6 ms (overstates) |
| n-weighted average of the two p99s | 971.9 ms | −185.0 ms (understates) |
| Merged exact p99 | 1156.9 ms | 0 (truth for this sample) |
histogram_quantile(0.99) on summed buckets | 1235.3 ms | +78.4 ms (bucket interpolation) |
Averaging quantiles is not “approximately right.” Unweighted avg overshot by more than a second; weighting by request count still missed. Only merging the distributions (raw samples or histogram buckets) recovers a coherent service-level p99.
Even for averages, weight by request count:
Not avg(rate(sum)/rate(count)) across instances without care — imbalance hides hot pods. Native histograms also expose histogram_avg(rate(...)) helpers; see the same docs page and Metric types.
Prometheus supports summaries (client computes quantiles) and histograms (buckets; server computes quantiles). Native histograms (and NHCB ingestion of classic histograms) are the modern preference when available.
| Need | Prefer | Avoid |
|---|---|---|
| Aggregate across replicas / shards | Histogram (native > classic) | Summary quantiles |
| Change φ or window later in PromQL | Histogram | Summary (fixed at instrumentation) |
| Ultra-precise φ on a single process, no merge | Summary can shine | — |
| Heatmaps over time | Higher-res native histograms | Ultra-coarse classic buckets |
Rules of thumb from Prometheus docs:
Further reading: Native Histograms spec.
If your SLO is “p95 < 300 ms,” put bucket boundaries near that region (0.1, 0.2, 0.3, 0.45, …). Too-wide buckets make histogram_quantile a rough interpolation — the docs’ sharp-spike thought experiment shows estimated p95 drifting toward the upper bound.
Lab buckets used (classic le, seconds): 0.005, 0.01, 0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 1.0, 2.5, 5.0, 10.0, +Inf. On the merged series, exact p99 was 1156.9 ms while histogram_quantile returned 1235.3 ms — coarse upper buckets (1s/2.5s/5s) pull the estimate. Optional next lab: capture your production/service bucket list from a live scrape (this lab is synthetic classic buckets).
_sum and _count (automatic for Prom histograms) for weighted means and QPS.histogram_quantile on sum(rate(...)), never avg of quantile series.| Panel | PromQL sketch | Reads as |
|---|---|---|
| Request rate | sum(rate(..._count[5m])) | Load |
| Mean latency | sum/count rates | Capacity / cost proxy |
| p50 / p95 / p99 | histogram_quantile | UX / SLO |
| Exhaustion | saturation metrics from host/cgroup | Tie to monitoring graphs |
Optional next lab: capture one Grafana row where mean looks calm and p99 breaches — same time range. The lab tables above already prove the math; a production screenshot is enrichment, optional.
Lab-measured (local histogram lab, not production Grafana): Healthy pod mean 26.4 ms / p99 117.5 ms. Sick canary mean 180.9 ms / p99 4389.6 ms. Merged service mean only 57.3 ms (easy to shrug), while merged p99 was 1156.9 ms. Unweighted avg of the two p99s invented 2253.6 ms — a number that matched neither pod nor the service. Summing histogram buckets and running histogram_quantile(0.99, …) recovered 1235.3 ms, in the right neighborhood of the true merge.
Optional next lab: swap this worked example for a real ShopperCove production incident when you have one, and optionally add a Grafana row screenshot (mean calm + p99 burn, same range).
Percentiles answer “how slow were requests?” Host graphs answer “was the machine sick?” You need both.
A useful incident habit:
histogram_fraction under your SLO threshold) burn.If p99 spikes while host golden signals are flat, look at dependency latency, lock contention, or slow queries. If p99 spikes with swap-in or CPU saturation, fix the box or the footprint first.
This post stays focused on distribution math and PromQL correctness. The graphs posts stay focused on reading panels. Link them; do not paste one into the other.
| Approach | Advantages | Disadvantages |
|---|---|---|
| Mean-only dashboards | Cheap; good for total work | Hides tails; bad UX SLI |
| p95/p99 from summaries | Accurate φ on one process | Cannot aggregate; inflexible window |
| Classic histograms | Aggregatable; flexible φ | Bucket planning; series cost |
| Native histograms | Aggregatable + resolution | Ecosystem maturity / scrape config; learn new APIs |
No fake case studies. No vendor crown.
Q1. Is p99 always better than p95 for SLOs?Not always. Higher percentiles are noisier and need more traffic for stable estimates. Pick φ from product risk; engineer bucket resolution to match.
Q2. Can I alert on mean and page on p99?Yes. Mean for capacity saturation; p99 (or a histogram fraction under a threshold) for UX SLOs.
Q3. Why did my p99 get worse after “fixing” histograms?Often coarser buckets or incorrect sum by (le). Verify bucket boundaries and that you sum before histogram_quantile.
Q4. Do percentiles include failed requests?Only if you observe them in the same histogram. Many teams separate success latency vs error count — be explicit on the panel.
Q5. Are Apdex and percentiles rivals?Apdex is a threshold-based satisfaction score; percentiles describe the distribution. Prometheus docs show Apdex via histogram_fraction. Use both carefully; do not equate them.
Q6. Should every microservice switch to native histograms tomorrow?Prefer them for new instrumentation when the stack supports protobuf/native scrape. Migrating classic → NHCB can be a bridge — validate in a staging scrape config first (optional next lab; this post’s numbers use classic buckets).
Lab evidence
Local histogram lab 29 Sep 2026 IST — merged mean 57.3 ms, p50 20.6, p95 42.0, p99 1156.9; unweighted avg-of-p99 footgun 2253.6 ms vs merged truth. Method: Python/numpy + classic histogram_quantile (not Grafana). Seed 20260929.
Explore the product
Untested by us. Check what's included, the license and refund terms before you buy.
A framework for turning dashboard noise into a diagnosis, based on the four golden signals and the symptom-versus-cause split — summarized from kciter's guide, with the author's hands-on verdict still to come.
Summary22 Sept 20266 min read
A framework for diagnosing dashboards: symptoms first, causes second, and why the average response time metric is lying to you.
Summary10 Sept 20266 min read
Summary30 Sept 20267 min read