Split View: 평균 응답 시간이 거짓말하는 이유 — p50, p95, p99를 제대로 읽는 법
평균 응답 시간이 거짓말하는 이유 — p50, p95, p99를 제대로 읽는 법
- 들어가며 — 평균은 80ms인데 왜 느리다는 항의가 들어오는가
- 평균이 감추는 것 — 롱테일 분포의 산술
- p50, p95, p99, p99.9가 각각 답하는 질문
- p99는 "100명 중 1명"이 아니다 — 지연의 곱셈 효과
- 백분위는 평균낼 수 없다 — 그래서 히스토그램이 필요하다
- 프로메테우스 히스토그램 — 버킷 경계와 보간 오차
- SLO를 백분위로 정의할 때의 실무 기준
- 마치며 — 하나의 숫자가 아니라 분포를 보라
들어가며 — 평균은 80ms인데 왜 느리다는 항의가 들어오는가
대시보드의 평균 응답 시간은 80ms입니다. 지난 한 달간 그래프는 평평했고 알림도 울리지 않았습니다. 그런데 고객지원 팀은 "결제 화면이 몇 초씩 멈춘다"는 문의를 매일 받고 있습니다.
두 사실은 모순이 아닙니다. 평균이 원래 그렇게 동작하는 지표이기 때문입니다. 평균은 분포의 모양을 하나의 숫자로 뭉개면서, 정확히 사용자가 화를 내는 구간의 정보를 가장 먼저 버립니다.
이 글은 그 구간을 다시 꺼내는 방법을 다룹니다. 백분위가 무엇을 답하는지, 왜 백분위끼리 평균낼 수 없는지, 그리고 프로메테우스 히스토그램이 실제로 어떤 숫자를 돌려주는지까지 계산 가능한 수준으로 내려가 봅니다.
평균이 감추는 것 — 롱테일 분포의 산술
응답 시간 분포는 정규분포가 아닙니다. 왼쪽에 벽이 있고(0ms보다 빠를 수 없습니다) 오른쪽으로 길게 늘어집니다. GC 정지, 콜드 캐시, 커넥션 풀 대기, 재시도, 시끄러운 이웃 — 느려지는 방법은 많고 빨라지는 방법은 없습니다.
숫자로 보겠습니다. 10,000건의 요청 중 9,900건이 50ms에 끝나고 100건이 3,000ms 걸렸다고 하겠습니다.
# 액세스 로그의 응답 시간(ms) 컬럼만 뽑아 직접 분포를 계산한다
awk '{print $NF}' access.log | sort -n > sorted.txt
wc -l < sorted.txt
# 10000
awk '{s+=$1} END {printf "mean %.1f\n", s/NR}' sorted.txt
# mean 79.5
for n in 5000 9500 9900 9990; do
printf "n=%s\t%s\n" "$n" "$(awk -v r=$n 'NR==r' sorted.txt)"
done
# n=5000 50 <- p50
# n=9500 50 <- p95
# n=9900 50 <- p99 (경계에 걸쳐 있다)
# n=9990 3000 <- p99.9
평균은 79.5ms입니다. 실제로 79.5ms에 응답받은 요청은 단 한 건도 없습니다. 평균은 존재하지 않는 사용자를 묘사합니다.
더 나쁜 것은 민감도입니다. 느린 100건이 3,000ms에서 6,000ms로 두 배 나빠져도 평균은 79.5ms에서 109.5ms로 움직일 뿐입니다. 30ms 상승은 어떤 알림 임계값도 넘지 않습니다. 반면 그 100건을 겪은 사용자에게는 서비스가 완전히 망가진 것으로 보입니다. 평균은 꼬리가 나빠지는 것을 거의 감지하지 못합니다.
p50, p95, p99, p99.9가 각각 답하는 질문
백분위마다 답하는 질문이 다릅니다. 하나만 보는 것은 하나만 보지 않는 것보다 나쁩니다.
| 지표 | 답하는 질문 | 대표적인 오해 |
|---|---|---|
| 평균 | 총 처리 시간을 요청 수로 나누면 얼마인가 | 전형적인 사용자 경험을 나타낸다 |
| p50 | 요청의 절반은 이 시간 안에 끝난다 | 대부분의 요청이 이 정도다 |
| p95 | 스무 번에 한 번 겪는 느림의 크기 | 무시해도 되는 예외값이다 |
| p99 | 백 번에 한 번 겪는 느림의 크기 | 사용자의 1%만 겪는다 |
| p99.9 | 인프라 이상과 GC 정지가 드러나는 구간 | 트래픽이 적어도 의미가 있다 |
실무적인 읽는 법은 이렇습니다.
- p50이 나빠지면 시스템 전체가 느려진 것입니다. 용량 부족이나 공통 의존성의 문제를 의심합니다.
- p50은 그대로인데 p99만 나빠지면 특정 조건에서만 발생하는 문제입니다. 특정 테넌트, 특정 쿼리 경로, 특정 노드, 락 경합, GC를 봅니다.
- p99와 p99.9의 간격이 벌어지면 드물지만 매우 비싼 경로가 생긴 것입니다. 재시도 폭풍이나 타임아웃 설정을 확인합니다.
- p50과 p99가 거의 같으면 분포가 압축된 것입니다. 좋은 신호일 수도 있지만, 앞단에서 타임아웃으로 잘리고 있을 가능성도 함께 봅니다.
p99.9는 트래픽 볼륨이 받쳐줄 때만 의미가 있습니다. 5분에 1,000건이 들어오는 서비스에서 p99.9는 단 한 건의 요청입니다. 한 건짜리 신호에 알림을 걸면 알림은 난수 발생기가 됩니다.
p99는 "100명 중 1명"이 아니다 — 지연의 곱셈 효과
가장 흔하고 가장 비싼 오해입니다. p99가 2초라는 말은 "사용자의 1%가 2초를 겪는다"가 아니라 "요청 100개 중 1개가 2초를 겪는다"입니다. 한 사용자가 여러 요청을 만든다면 체감 확률은 요청 수만큼 곱해집니다.
// 한 화면이 N개의 백엔드 호출을 부를 때
// "적어도 하나가 p99를 넘을" 확률
const p = 0.99
for (const n of [1, 5, 20, 50, 200]) {
const hit = 1 - Math.pow(p, n)
console.log(`${String(n).padStart(3)}회 호출 → ${(hit * 100).toFixed(1)}%`)
}
// 1회 호출 → 1.0%
// 5회 호출 → 4.9%
// 20회 호출 → 18.2%
// 50회 호출 → 39.5%
// 200회 호출 → 86.6%
대시보드 한 화면이 20개의 API를 호출하는 구조라면, 화면을 한 번 열 때 p99 지연을 만날 확률은 18%입니다. 하루에 200번 요청을 만드는 활성 사용자라면 87%가 하루에 한 번 이상 최악 구간을 겪습니다. p99는 소수의 불운한 사용자가 아니라 거의 모든 사용자의 일상입니다.
여기서 두 가지 결론이 나옵니다. 첫째, 팬아웃이 큰 아키텍처일수록 꼬리 지연 관리가 중요합니다. 서비스 하나의 p99를 개선하는 것이 전체 화면의 p50을 개선하는 것보다 효과가 큽니다. 둘째, 클라이언트 측 지표를 따로 봐야 합니다. 서버의 p99가 아니라 화면 완성 시점의 백분위가 사용자가 실제로 겪는 값입니다.
백분위는 평균낼 수 없다 — 그래서 히스토그램이 필요하다
이 절이 이 글에서 가장 실무적으로 중요한 부분입니다. 합계와 개수는 더할 수 있지만 백분위는 더하거나 평균낼 수 없습니다.
인스턴스 두 대를 예로 들겠습니다.
- 서버 A: 요청 9,900건, 전부 50ms → p99(A) = 50ms
- 서버 B: 요청 100건, 전부 3,000ms → p99(B) = 3,000ms
두 p99를 평균내면 1,525ms입니다. 요청 수로 가중 평균하면 79.5ms입니다. 그런데 두 서버의 요청 10,000건을 한 줄로 세워 계산한 진짜 p99는 3,000ms입니다. 세 숫자가 전부 다르고, 앞의 두 개는 아무 의미가 없습니다.
그래서 다음 PromQL은 틀렸습니다.
# 틀림: 인스턴스별 p99를 각각 구한 뒤 평균낸다
avg(
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
올바른 순서는 버킷 카운터를 먼저 합치고, 합쳐진 분포에서 백분위를 계산하는 것입니다. 카운터는 집계 가능하고 백분위는 집계 불가능하기 때문입니다.
# 맞음: le 레이블 기준으로 rate를 먼저 합산한 뒤 백분위를 계산한다
histogram_quantile(
0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
서비스별로 나누어 보고 싶다면 sum by 절에 le와 함께 그 레이블을 넣습니다. le는 절대 빠지면 안 됩니다.
# 라우트별 p99 — le 와 route 를 함께 유지한다
histogram_quantile(
0.99,
sum by (le, route) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
같은 이유로 프로메테우스의 summary 타입은 인스턴스 안에서 이미 백분위를 계산해 내보내기 때문에, 여러 인스턴스나 여러 라우트를 합칠 방법이 없습니다. 단일 프로세스의 로컬 진단에는 쓸 수 있지만 서비스 수준 지표로는 쓸 수 없습니다. 히스토그램은 정확도를 버킷 해상도만큼 포기하는 대신 집계 가능성을 얻습니다. 분산 시스템에서는 거의 항상 후자가 맞는 거래입니다.
프로메테우스 히스토그램 — 버킷 경계와 보간 오차
histogram_quantile이 돌려주는 값은 관측된 실제 값이 아닙니다. 버킷 경계 사이를 직선으로 이은 추정치입니다.
curl -s localhost:8080/metrics | grep '^http_request_duration_seconds_bucket'
# http_request_duration_seconds_bucket{le="0.005"} 0
# http_request_duration_seconds_bucket{le="0.01"} 0
# http_request_duration_seconds_bucket{le="0.025"} 12
# http_request_duration_seconds_bucket{le="0.05"} 4103
# http_request_duration_seconds_bucket{le="0.1"} 8890
# http_request_duration_seconds_bucket{le="0.25"} 9604
# http_request_duration_seconds_bucket{le="0.5"} 9740
# http_request_duration_seconds_bucket{le="1"} 9812
# http_request_duration_seconds_bucket{le="2.5"} 9993
# http_request_duration_seconds_bucket{le="5"} 10000
# http_request_duration_seconds_bucket{le="+Inf"} 10000
p99는 9,900번째 관측값입니다. 이 값은 le=1 버킷(누적 9,812)과 le=2.5 버킷(누적 9,993) 사이에 있습니다. 선형 보간은 이렇게 계산합니다.
python3 - <<'PY'
lo, hi = 1.0, 2.5
c_lo, c_hi = 9812, 9993
target = 0.99 * 10000
est = lo + (target - c_lo) / (c_hi - c_lo) * (hi - lo)
print(f"p99 estimate = {est:.3f}s")
PY
# p99 estimate = 1.729s
1.729초라는 정밀해 보이는 숫자가 나오지만, 이 버킷 안의 181건이 실제로 어디에 있는지는 아무도 모릅니다. 전부 1.05초일 수도 있고 전부 2.4초일 수도 있습니다. 버킷이 넓으면 histogram_quantile의 출력은 소수점 세 자리까지 나오는 추측입니다.
여기서 나오는 실무 규칙이 세 가지입니다.
첫째, SLO 임계값과 정확히 같은 버킷 경계를 반드시 넣습니다. 300ms를 기준으로 삼을 것이라면 le=0.3 버킷이 있어야 합니다. 경계가 있으면 보간 없이 정확한 비율을 셀 수 있습니다.
둘째, 관심 구간에 버킷을 촘촘하게 둡니다. 기본 버킷은 웹 API 기준으로 0.1초 위쪽이 너무 성깁니다.
// prom-client — 서비스의 실제 분포에 맞춰 경계를 직접 정한다
const { Histogram } = require('prom-client')
const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP 요청 처리 시간',
labelNames: ['method', 'route', 'code'],
// SLO 임계값 0.3 을 경계로 포함하고, 100ms~1s 구간을 촘촘히 둔다
buckets: [0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.6, 0.8, 1, 2, 5, 10],
})
셋째, 최상단 유한 버킷을 실제 타임아웃보다 크게 잡습니다. p99가 마지막 유한 경계를 넘어가면 histogram_quantile은 그 경계값 자체를 돌려주거나(구현에 따라) 상한에 붙어버려서, 얼마나 나쁜지 알 수 없게 됩니다.
버킷 경계를 직접 고르는 일 자체가 부담이라면 프로메테우스의 네이티브 히스토그램을 검토할 만합니다. 지수 간격의 버킷을 자동으로 만들어 주므로 경계 선택 문제와 해상도 문제를 상당 부분 없애줍니다. 다만 저장 포맷과 쿼리 경로, 원격 저장소 호환성이 클래식 히스토그램과 다르므로 스택 전체의 지원 여부를 먼저 확인해야 합니다.
SLO를 백분위로 정의할 때의 실무 기준
여기서 마지막 반전이 있습니다. 지연 SLO를 "p99가 300ms 이하"로 정의하는 것은 실무에서 좋은 선택이 아닙니다.
이유는 앞에서 다 나왔습니다. 백분위는 집계할 수 없고, 시간 축으로도 합칠 수 없습니다. 5분 창의 p99를 30일치 모아 놓아도 30일의 p99가 되지 않습니다. 에러 버짓을 계산할 수도, 번 레이트를 구할 수도 없습니다.
대신 같은 내용을 비율로 표현합니다. "요청의 99%가 300ms 안에 완료된다"는 문장은 임계값을 넘긴 요청의 비율로 직접 계산되고, 이 비율은 더할 수 있습니다.
# 지연 SLI: 300ms 이내에 완료된 요청의 비율
sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[30d]))
/
sum(rate(http_request_duration_seconds_count{job="checkout-api"}[30d]))
# 그대로 에러 버짓 소진율로 이어진다 (목표 99%, 즉 예산 1%)
(
1 -
sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[1h]))
/
sum(rate(http_request_duration_seconds_count{job="checkout-api"}[1h]))
) / 0.01
이 형태의 장점은 세 가지입니다. 보간 오차가 없습니다(le=0.3 버킷을 그대로 세기 때문입니다). 어떤 시간 창으로도 다시 계산할 수 있습니다. 그리고 번 레이트 알림에 그대로 꽂힙니다.
기준을 정할 때 참고할 실무 값도 정리해 둡니다.
- 목표 백분위는 사용자 여정의 팬아웃을 고려해 정합니다. 한 화면이 20개를 호출한다면 서비스 하나의 목표는 99%가 아니라 99.9%여야 화면 수준에서 98%가 나옵니다.
- 임계값은 사용자 인지 한계에서 역산합니다. 즉시 반응으로 느끼는 100ms, 흐름이 끊기지 않는 1초, 주의가 이탈하는 10초가 오래된 기준선이고 지금도 유효합니다.
- 측정 창은 28일 또는 30일 롤링으로 두고, 배포 주기보다 길게 잡습니다. 창이 짧으면 배포 한 번의 실수가 곧바로 예산을 다 태웁니다.
- 백분위 대시보드는 계속 봅니다. SLO는 비율로 관리하되, 원인 조사는 p50과 p99, p99.9를 함께 겹쳐 보는 그래프에서 시작합니다.
마치며 — 하나의 숫자가 아니라 분포를 보라
기억할 것은 한 문장입니다. 평균은 분포를 하나의 숫자로 뭉개면서 사용자가 화를 내는 부분을 가장 먼저 버리고, 백분위는 그 부분을 보여주지만 절대 다시 합칠 수 없습니다.
그래서 실무의 순서는 이렇게 됩니다. 히스토그램으로 원본 분포를 보존하고, 버킷 경계에 SLO 임계값을 심어 두고, 백분위는 사람이 보는 대시보드에서만 쓰고, 알림과 SLO는 집계 가능한 비율로 정의합니다. 평균 응답 시간 그래프는 지워도 됩니다. 그 자리에 p50과 p99를 나란히 놓는 것만으로 대부분의 팀은 이미 보이지 않던 사고를 보기 시작합니다.
더 파고들 자료입니다.
Why Average Response Time Lies — How to Read p50, p95, and p99 Properly
- Introduction — The Average Is 80ms, So Why Are the Complaints Coming In
- What the Average Hides — The Arithmetic of a Long Tail
- The Question Each of p50, p95, p99, and p99.9 Answers
- p99 Is Not "One User in a Hundred" — The Multiplicative Effect of Latency
- Percentiles Cannot Be Averaged — Which Is Why You Need Histograms
- Prometheus Histograms — Bucket Boundaries and Interpolation Error
- Practical Criteria for Defining an SLO with Percentiles
- Closing — Look at the Distribution, Not a Single Number
Introduction — The Average Is 80ms, So Why Are the Complaints Coming In
The dashboard says the average response time is 80ms. The graph has been flat for a month and no alert has fired. Meanwhile the support team receives a ticket every single day saying the checkout screen freezes for seconds at a time.
The two facts are not contradictory. The average is simply a metric that behaves this way. It flattens the shape of a distribution into one number, and the first thing it discards is precisely the region where users get angry.
This post is about digging that region back out. What percentiles answer, why percentiles cannot be averaged with one another, and what numbers a Prometheus histogram actually hands back — all the way down to a level where you can do the arithmetic yourself.
What the Average Hides — The Arithmetic of a Long Tail
Response time distributions are not normal distributions. There is a wall on the left (nothing can be faster than 0ms) and a long stretch to the right. GC pauses, cold caches, connection pool waits, retries, noisy neighbours — there are many ways to get slower and none to get faster.
Let us look at the numbers. Suppose that out of 10,000 requests, 9,900 finish in 50ms and 100 take 3,000ms.
# Pull only the response time (ms) column out of the access log and compute the distribution directly
awk '{print $NF}' access.log | sort -n > sorted.txt
wc -l < sorted.txt
# 10000
awk '{s+=$1} END {printf "mean %.1f\n", s/NR}' sorted.txt
# mean 79.5
for n in 5000 9500 9900 9990; do
printf "n=%s\t%s\n" "$n" "$(awk -v r=$n 'NR==r' sorted.txt)"
done
# n=5000 50 <- p50
# n=9500 50 <- p95
# n=9900 50 <- p99 (sitting right on the boundary)
# n=9990 3000 <- p99.9
The average is 79.5ms. Not a single request was actually served in 79.5ms. The average describes a user who does not exist.
Sensitivity is worse still. If those 100 slow requests double from 3,000ms to 6,000ms, the average moves only from 79.5ms to 109.5ms. A 30ms rise crosses no alert threshold anywhere. To the users who hit those 100 requests, however, the service looks completely broken. The average barely detects the tail getting worse.
The Question Each of p50, p95, p99, and p99.9 Answers
Every percentile answers a different question. Looking at only one is worse than looking at none.
| Metric | Question it answers | Common misreading |
|---|---|---|
| Average | Total processing time divided by request count | It represents the typical user experience |
| p50 | Half of all requests finish within this time | Most requests are around this value |
| p95 | The size of the slowness you hit one time in twenty | It is an outlier you can ignore |
| p99 | The size of the slowness you hit one time in a hundred | Only 1% of users experience it |
| p99.9 | The region where infrastructure anomalies and GC pauses surface | It is meaningful even at low traffic |
Here is how to read them in practice.
- If p50 gets worse, the whole system slowed down. Suspect capacity shortfalls or a shared dependency.
- If p50 is unchanged but only p99 degrades, the problem occurs under specific conditions. Look at a particular tenant, a particular query path, a particular node, lock contention, or GC.
- If the gap between p99 and p99.9 widens, a rare but very expensive path has appeared. Check for retry storms or timeout settings.
- If p50 and p99 are nearly identical, the distribution has been compressed. That may be a good sign, but also consider the possibility that something upstream is cutting requests off with a timeout.
p99.9 only means something when traffic volume supports it. In a service taking 1,000 requests every five minutes, p99.9 is a single request. Alert on a one-request signal and the alert becomes a random number generator.
p99 Is Not "One User in a Hundred" — The Multiplicative Effect of Latency
This is the most common and most expensive misunderstanding. A p99 of 2 seconds does not mean "1% of users experience 2 seconds", it means "1 out of every 100 requests experiences 2 seconds". If a single user generates many requests, the probability they feel it multiplies by the number of requests.
// When one screen issues N backend calls,
// the probability that "at least one exceeds p99"
const p = 0.99
for (const n of [1, 5, 20, 50, 200]) {
const hit = 1 - Math.pow(p, n)
console.log(`${String(n).padStart(3)} calls → ${(hit * 100).toFixed(1)}%`)
}
// 1 calls → 1.0%
// 5 calls → 4.9%
// 20 calls → 18.2%
// 50 calls → 39.5%
// 200 calls → 86.6%
If one dashboard screen calls 20 APIs, the chance of meeting p99 latency each time that screen opens is 18 percent. For an active user who generates 200 requests a day, 87 percent will hit the worst-case region at least once a day. p99 is not a handful of unlucky users, it is the daily reality of nearly all of them.
Two conclusions follow. First, the larger the fan-out in an architecture, the more tail latency management matters. Improving the p99 of a single service has a bigger effect than improving the p50 of the whole screen. Second, you need to watch client-side metrics separately. What the user actually experiences is the percentile at the moment the screen is complete, not the server p99.
Percentiles Cannot Be Averaged — Which Is Why You Need Histograms
This section is the most practically important part of the post. Sums and counts can be added together, but percentiles cannot be added or averaged.
Take two instances as an example.
- Server A: 9,900 requests, all 50ms → p99(A) = 50ms
- Server B: 100 requests, all 3,000ms → p99(B) = 3,000ms
Averaging the two p99 values gives 1,525ms. Weighting the average by request count gives 79.5ms. But the true p99, computed by lining up all 10,000 requests from both servers, is 3,000ms. All three numbers differ, and the first two mean nothing at all.
That is why the following PromQL is wrong.
# Wrong: compute p99 per instance and then average the results
avg(
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
The correct order is to sum the bucket counters first and then compute the percentile from the combined distribution. Counters are aggregatable; percentiles are not.
# Right: sum the rates by the le label first, then compute the percentile
histogram_quantile(
0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
If you want to break the view down by service, put that label into the sum by clause alongside le. le must never be dropped.
# p99 per route — keep le and route together
histogram_quantile(
0.99,
sum by (le, route) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
For the same reason, the Prometheus summary type computes percentiles inside the instance before exporting them, so there is no way to combine multiple instances or multiple routes. It is usable for local diagnosis of a single process but not as a service-level metric. A histogram gives up accuracy to the extent of its bucket resolution and gains aggregatability in return. In a distributed system, that is almost always the right trade.
Prometheus Histograms — Bucket Boundaries and Interpolation Error
The value histogram_quantile returns is not an observed value. It is an estimate produced by drawing a straight line between bucket boundaries.
curl -s localhost:8080/metrics | grep '^http_request_duration_seconds_bucket'
# http_request_duration_seconds_bucket{le="0.005"} 0
# http_request_duration_seconds_bucket{le="0.01"} 0
# http_request_duration_seconds_bucket{le="0.025"} 12
# http_request_duration_seconds_bucket{le="0.05"} 4103
# http_request_duration_seconds_bucket{le="0.1"} 8890
# http_request_duration_seconds_bucket{le="0.25"} 9604
# http_request_duration_seconds_bucket{le="0.5"} 9740
# http_request_duration_seconds_bucket{le="1"} 9812
# http_request_duration_seconds_bucket{le="2.5"} 9993
# http_request_duration_seconds_bucket{le="5"} 10000
# http_request_duration_seconds_bucket{le="+Inf"} 10000
p99 is the 9,900th observation. That value sits between the le=1 bucket (cumulative 9,812) and the le=2.5 bucket (cumulative 9,993). Linear interpolation computes it like this.
python3 - <<'PY'
lo, hi = 1.0, 2.5
c_lo, c_hi = 9812, 9993
target = 0.99 * 10000
est = lo + (target - c_lo) / (c_hi - c_lo) * (hi - lo)
print(f"p99 estimate = {est:.3f}s")
PY
# p99 estimate = 1.729s
Out comes 1.729 seconds, a number that looks precise, yet nobody knows where inside that bucket the 181 observations actually fall. They could all be 1.05 seconds or all 2.4 seconds. When buckets are wide, the output of histogram_quantile is a guess reported to three decimal places.
Three practical rules follow.
First, always include a bucket boundary that exactly matches your SLO threshold. If you are going to use 300ms as the reference, there must be an le=0.3 bucket. With the boundary in place you can count the exact ratio without interpolation.
Second, place buckets densely in the region you care about. The default buckets are far too sparse above 0.1 seconds for a web API.
// prom-client — set the boundaries yourself to match the real distribution of the service
const { Histogram } = require('prom-client')
const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request processing time',
labelNames: ['method', 'route', 'code'],
// Include the SLO threshold 0.3 as a boundary and pack the 100ms~1s region densely
buckets: [0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.6, 0.8, 1, 2, 5, 10],
})
Third, set the highest finite bucket above the actual timeout. Once p99 pushes past the last finite boundary, histogram_quantile either returns that boundary value itself (depending on the implementation) or pins to the ceiling, and you lose all information about how bad things really are.
If choosing bucket boundaries by hand is a burden in itself, Prometheus native histograms are worth evaluating. They generate exponentially spaced buckets automatically, which removes much of the boundary-selection and resolution problem. Note, though, that the storage format, query path, and remote-storage compatibility differ from classic histograms, so check support across your whole stack first.
Practical Criteria for Defining an SLO with Percentiles
Here comes the final twist. Defining a latency SLO as "p99 at or below 300ms" is not a good choice in practice.
The reasons all appeared above. Percentiles cannot be aggregated, and they cannot be combined along the time axis either. Collecting 30 days of five-minute-window p99 values does not give you the p99 of 30 days. You cannot compute an error budget or derive a burn rate from them.
Express the same content as a ratio instead. The sentence "99% of requests complete within 300ms" is computed directly from the fraction of requests that crossed the threshold, and that fraction can be added.
# Latency SLI: the fraction of requests completed within 300ms
sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[30d]))
/
sum(rate(http_request_duration_seconds_count{job="checkout-api"}[30d]))
# It leads straight into error budget burn (target 99%, i.e. a 1% budget)
(
1 -
sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[1h]))
/
sum(rate(http_request_duration_seconds_count{job="checkout-api"}[1h]))
) / 0.01
This form has three advantages. There is no interpolation error (because it counts the le=0.3 bucket directly). It can be recomputed over any time window. And it plugs straight into burn rate alerts.
Here are some practical reference values for setting the criteria.
- Choose the target percentile with the fan-out of the user journey in mind. If one screen makes 20 calls, the target for a single service has to be 99.9% rather than 99% to yield 98% at the screen level.
- Derive the threshold backwards from the limits of human perception. The 100ms that feels instantaneous, the 1 second that keeps flow unbroken, and the 10 seconds where attention departs are old baselines and still valid.
- Set the measurement window as a rolling 28 or 30 days, longer than your deployment cycle. If the window is short, one mistake in one deployment burns the entire budget immediately.
- Keep watching percentile dashboards. Manage the SLO as a ratio, but start root-cause investigation from a graph that overlays p50, p99, and p99.9 together.
Closing — Look at the Distribution, Not a Single Number
There is one sentence to remember. The average flattens a distribution into one number and discards the part where users get angry first, while percentiles show you that part but can never be combined again.
So the practical order becomes this. Preserve the raw distribution with a histogram, plant the SLO threshold in a bucket boundary, use percentiles only on dashboards that humans read, and define alerts and SLOs as aggregatable ratios. You can delete the average response time graph. Simply putting p50 and p99 side by side in that spot is enough for most teams to start seeing incidents that were invisible before.
Further reading.