How to check p95 latency
Sort every response time from one run and take the value at the 95th position out of 100. autocannon prints p90 and p97.5 but never p95, so collect the raw durations and compute it: node pct.mjs returned 111.42 ms where the mean of the same 16 647 requests was 24.02 ms.
Why check this
A mean hides the requests that lose customers. Run this on staging before sign-off, and after any change that adds a conditional slow path: a cache that can miss, a retry, a report query that fires only for large accounts.
The failure it catches is a route that is fast for most requests and unusable for a tenth of them. Below, two routes on one server return the same median of 15 ms, and one answers 1 request in 10 at around 110 ms. A dashboard of averages calls them equal.
Latency over loopback contains no network. Every figure here is handler service time, not what a user two countries away sees.
Prerequisites
- Node 22 or later, and
npx autocannon@8, which fetches autocannon on first use. - A free port. Port 9662 is used below.
netstat -ano | grep ":9662 " | grep -c LISTENINGprints 0 when it is free. - The target, saved as
p95-server.mjsand started withnode p95-server.mjs &./uniformwaits 10 ms./bimodalwaits 1 ms, except every tenth, which waits 100 ms.
// p95-server.mjs
import { createServer } from 'node:http';
let n = 0;
createServer((req, res) => {
const send = () => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); };
if (req.url === '/uniform') return void setTimeout(send, 10);
n += 1;
setTimeout(send, n % 10 === 0 ? 100 : 1);
}).listen(9662, '127.0.0.1', () => console.log('listening on 9662'));
samples.mjs, which records the duration of every request.
// samples.mjs <url> <connections> <seconds>
import { writeFileSync } from 'node:fs';
const [url, conns, secs] = [process.argv[2], +process.argv[3], +process.argv[4]];
const rows = [];
const t0 = Date.now();
async function worker() {
while (Date.now() - t0 < secs * 1000) {
const s = performance.now();
const r = await fetch(url);
await r.arrayBuffer();
rows.push([((Date.now() - t0) / 1000).toFixed(3), (performance.now() - s).toFixed(3)]);
}
}
await Promise.all(Array.from({ length: conns }, worker));
writeFileSync('samples.csv', rows.map((r) => r.join(',')).join('\n'));
console.log(`${rows.length} samples written to samples.csv`);
pct.mjs, which takes percentiles two ways from one file.
// pct.mjs
import { readFileSync } from 'node:fs';
const rows = readFileSync('samples.csv', 'utf8').trim().split('\n')
.map((l) => l.split(',').map(Number));
const pct = (a, p) => a.slice().sort((x, y) => x - y)[Math.ceil((p / 100) * a.length) - 1];
const all = rows.map((r) => r[1]);
const buckets = new Map();
for (const [t, d] of rows) {
const s = Math.floor(t);
if (!buckets.has(s)) buckets.set(s, []);
buckets.get(s).push(d);
}
const means = [...buckets.values()].map((v) => v.reduce((a, b) => a + b) / v.length);
const f = (x) => x.toFixed(2);
console.log(`samples ${all.length}`);
console.log(`mean ${f(all.reduce((a, b) => a + b) / all.length)} ms`);
console.log(`p50 raw ${f(pct(all, 50))} ms`);
console.log(`p95 raw ${f(pct(all, 95))} ms`);
console.log(`p99 raw ${f(pct(all, 99))} ms`);
console.log(`max ${f(Math.max(...all))} ms`);
console.log(`per-second means ${means.length}`);
console.log(`p95 of means ${f(pct(means, 95))} ms`);
window.mjs, which cuts that file into fixed-size windows.
// window.mjs
import { readFileSync } from 'node:fs';
const d = readFileSync('samples.csv', 'utf8').trim().split('\n').map((l) => +l.split(',')[1]);
const pct = (a, p) => a.slice().sort((x, y) => x - y)[Math.ceil((p / 100) * a.length) - 1];
for (const n of [200, 2000, d.length]) {
const out = [];
for (let i = 0; i + n <= d.length && out.length < 8; i += n) out.push(pct(d.slice(i, i + n), 99).toFixed(1));
console.log(`n=${String(n).padEnd(6)} tail above p99: ${Math.round(n * 0.01)} p99 per window: ${out.join(' ')}`);
}
Steps
- Step 1.
Send twelve requests one at a time and look at the shape.
for i in $(seq 1 12); do curl -s -o /dev/null -w "request $i %{time_total}s\n" http://127.0.0.1:9662/bimodal; donerequest 1 0.006684s request 2 0.002960s request 3 0.005417s request 4 0.007459s request 5 0.006202s request 6 0.015035s request 7 0.001508s request 8 0.016542s request 9 0.002565s request 10 0.105262s request 11 0.017460s request 12 0.021681sEleven requests between 0.0015 s and 0.0217 s, and request 10 at 0.105262 s. Their mean is near 0.017 s, a value no single request came close to.
- Step 2.
Load the route with no slow path.
-ladds the full percentile table.npx autocannon@8 -c 20 -d 20 -l http://127.0.0.1:9662/uniformRunning 20s test @ http://127.0.0.1:9662/uniform 20 connections ┌─────────┬───────┬───────┬───────┬───────┬─────────┬─────────┬───────┐ │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ ├─────────┼───────┼───────┼───────┼───────┼─────────┼─────────┼───────┤ │ Latency │ 13 ms │ 15 ms │ 24 ms │ 25 ms │ 15.6 ms │ 2.21 ms │ 36 ms │ └─────────┴───────┴───────┴───────┴───────┴─────────┴─────────┴───────┘ ┌───────────┬────────┬────────┬────────┬────────┬──────────┬─────────┬────────┐ │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ ├───────────┼────────┼────────┼────────┼────────┼──────────┼─────────┼────────┤ │ Req/Sec │ 1 160 │ 1 160 │ 1 246 │ 1 280 │ 1 245,41 │ 29,55 │ 1 160 │ ├───────────┼────────┼────────┼────────┼────────┼──────────┼─────────┼────────┤ │ Bytes/Sec │ 214 kB │ 214 kB │ 229 kB │ 236 kB │ 229 kB │ 5.45 kB │ 213 kB │ └───────────┴────────┴────────┴────────┴────────┴──────────┴─────────┴────────┘ Req/Bytes counts sampled once per second. # of samples: 20 ┌────────────┬──────────────┐ │ Percentile │ Latency (ms) │ ├────────────┼──────────────┤ │ 0.001 │ 9 │ ├────────────┼──────────────┤ │ 0.01 │ 9 │ ├────────────┼──────────────┤ │ 0.1 │ 10 │ ├────────────┼──────────────┤ │ 1 │ 11 │ ├────────────┼──────────────┤ │ 2.5 │ 13 │ ├────────────┼──────────────┤ │ 10 │ 15 │ ├────────────┼──────────────┤ │ 25 │ 15 │ ├────────────┼──────────────┤ │ 50 │ 15 │ ├────────────┼──────────────┤ │ 75 │ 16 │ ├────────────┼──────────────┤ │ 90 │ 16 │ ├────────────┼──────────────┤ │ 97.5 │ 24 │ ├────────────┼──────────────┤ │ 99 │ 25 │ ├────────────┼──────────────┤ │ 99.9 │ 32 │ ├────────────┼──────────────┤ │ 99.99 │ 36 │ ├────────────┼──────────────┤ │ 99.999 │ 36 │ └────────────┴──────────────┘ 25k requests in 20.15s, 4.58 MB readThe baseline shape:
Avg15.6 ms, p50 15, p90 16, p97.5 24,Max36. Mean and median agree within a millisecond, the shape of one service time under light queueing. - Step 3.
Load the route with the slow tenth, at the same connection count and duration.
npx autocannon@8 -c 20 -d 20 -l http://127.0.0.1:9662/bimodalRunning 20s test @ http://127.0.0.1:9662/bimodal 20 connections ┌─────────┬──────┬───────┬────────┬────────┬──────────┬──────────┬────────┐ │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ ├─────────┼──────┼───────┼────────┼────────┼──────────┼──────────┼────────┤ │ Latency │ 0 ms │ 15 ms │ 110 ms │ 112 ms │ 21.24 ms │ 29.37 ms │ 122 ms │ └─────────┴──────┴───────┴────────┴────────┴──────────┴──────────┴────────┘ ┌───────────┬────────┬────────┬────────┬────────┬────────┬─────────┬────────┐ │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ ├───────────┼────────┼────────┼────────┼────────┼────────┼─────────┼────────┤ │ Req/Sec │ 852 │ 852 │ 901 │ 1 105 │ 923,45 │ 71,17 │ 852 │ ├───────────┼────────┼────────┼────────┼────────┼────────┼─────────┼────────┤ │ Bytes/Sec │ 157 kB │ 157 kB │ 166 kB │ 203 kB │ 170 kB │ 13.1 kB │ 157 kB │ └───────────┴────────┴────────┴────────┴────────┴────────┴─────────┴────────┘ Req/Bytes counts sampled once per second. # of samples: 20 ┌────────────┬──────────────┐ │ Percentile │ Latency (ms) │ ├────────────┼──────────────┤ │ 0.001 │ 0 │ ├────────────┼──────────────┤ │ 0.01 │ 0 │ ├────────────┼──────────────┤ │ 0.1 │ 0 │ ├────────────┼──────────────┤ │ 1 │ 0 │ ├────────────┼──────────────┤ │ 2.5 │ 0 │ ├────────────┼──────────────┤ │ 10 │ 1 │ ├────────────┼──────────────┤ │ 25 │ 8 │ ├────────────┼──────────────┤ │ 50 │ 15 │ ├────────────┼──────────────┤ │ 75 │ 15 │ ├────────────┼──────────────┤ │ 90 │ 27 │ ├────────────┼──────────────┤ │ 97.5 │ 110 │ ├────────────┼──────────────┤ │ 99 │ 112 │ ├────────────┼──────────────┤ │ 99.9 │ 115 │ ├────────────┼──────────────┤ │ 99.99 │ 120 │ ├────────────┼──────────────┤ │ 99.999 │ 122 │ └────────────┴──────────────┘ 18k requests in 20.15s, 3.4 MB readp50 is 15 ms on both routes. The tails are not comparable: p90 moves from 16 to 27, p97.5 from 24 to 110,
Maxfrom 36 to 122. Now look down the percentile list for the row you came for: it runs 90, 97.5, 99. - Step 4.
Record the duration of every request yourself.
node samples.mjs http://127.0.0.1:9662/bimodal 20 2016647 samples written to samples.csvEach line of
samples.csvholds one request: seconds since the run started, and the duration in milliseconds. A percentile needs those values, and no summary table gives them back. - Step 5.
Take p95 twice from that file: from the raw durations, then from the per-second averages of the same durations.
node pct.mjssamples 16647 mean 24.02 ms p50 raw 15.50 ms p95 raw 111.42 ms p99 raw 126.22 ms max 136.75 ms per-second means 21 p95 of means 28.02 msp95 of the 16 647 requests is 111.42 ms. p95 of the 21 per-second means built from those same requests is 28.02 ms. Averaging inside each second destroys the tail before the percentile is taken. A pipeline storing one average per second per instance produces the second number and labels it p95.
- Step 6.
Cut the same run into windows and take p99 of each.
node window.mjsn=200 tail above p99: 2 p99 per window: 112.5 108.0 116.4 121.0 113.4 114.3 115.5 117.5 n=2000 tail above p99: 20 p99 per window: 116.9 125.1 126.4 126.0 127.0 126.5 126.1 125.7 n=16647 tail above p99: 166 p99 per window: 126.2A p99 over 200 requests is the second slowest of 200. Every window here landed between 108.0 ms and 121.0 ms, all eight under the 126.2 ms of the full run. Small windows do not scatter around the true value, they sit below it.
- Step 7.
Stop the target and confirm the port is clear.
netstat -ano | grep "127.0.0.1:9662 " | grep LISTENING; powershell -Command "Stop-Process -Id 27880 -Force"; netstat -ano | grep ":9662 " | grep -c LISTENINGTCP 127.0.0.1:9662 0.0.0.0:0 LISTENING 27880 0Read the process id from the last column and stop that id alone.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Mean and p50 within a millisecond | One service time, light queueing | The mean is usable here. Keep p95 for the day it stops being true. |
| p50 flat, p97.5 four times higher | A slow path that some requests take | Find what those requests have in common before tuning anything else. |
| p95 close to Max | The tail is a cliff, not a slope | Look for a timeout, a retry or a lock, and count the requests above it. |
| 0 ms rows in the percentile list | Sub-millisecond responses, rounded down | The table is quantised to whole milliseconds. Use raw samples below 2 ms. |
| p99 moves on every run | Too few requests behind it | Raise the duration until p99 repeats across three runs. |
Common mistakes
What to check next
- How to check requests per second an api can handle: the throughput half of the same run.
- How to test concurrent users: what this distribution does when connections rise.
- Error rate in performance testing: fast error responses pull a percentile down and read as a win.
- How to read load test results: the rest of the table around the latency rows.
- How to check API response time: the single-request figure, and where it stops being enough.
FAQ
What is a good p95 latency?
No universal figure exists. It is a target your service sets and measures against, written next to the endpoint and the load it holds at. The route above reported 111.42 ms at 20 connections on loopback, which says nothing about whether that suits it.
What does p99 latency mean?
99 requests in 100 finished faster than that value. It describes a user who makes many calls, since anyone issuing 100 requests meets their p99 once. It needs more samples than p95: a p99 of 200 requests rests on 2 of them.
How do I calculate p95?
Sort the durations ascending and take the value at index ceil(0.95 * n) - 1. That is the whole of pct.mjs. Never average first: bucketing removed the tail and turned 111.42 ms into 28.02 ms.
What is 95th percentile response time?
The response time that 95% of requests beat. It is taken from the measured set rather than computed as a centre, so a skew does not move it the way it moves a mean. On the bimodal route the mean was 24.02 ms and p95 111.42 ms.
Verified
Verified by Maks Vernyautocannon 8.0.0node 22.23.2curl 8.21.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate14 minpublished updated Maks Verny