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

// 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 <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
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
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

  1. 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; done
    
    request 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.021681s

    Eleven 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.

  2. Step 2.

    Load the route with no slow path. -l adds the full percentile table.

    npx autocannon@8 -c 20 -d 20 -l http://127.0.0.1:9662/uniform
    
    Running 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 read

    The baseline shape: Avg 15.6 ms, p50 15, p90 16, p97.5 24, Max 36. Mean and median agree within a millisecond, the shape of one service time under light queueing.

  3. 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/bimodal
    
    Running 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 read

    p50 is 15 ms on both routes. The tails are not comparable: p90 moves from 16 to 27, p97.5 from 24 to 110, Max from 36 to 122. Now look down the percentile list for the row you came for: it runs 90, 97.5, 99.

  4. Step 4.

    Record the duration of every request yourself.

    node samples.mjs http://127.0.0.1:9662/bimodal 20 20
    
    16647 samples written to samples.csv

    Each line of samples.csv holds one request: seconds since the run started, and the duration in milliseconds. A percentile needs those values, and no summary table gives them back.

  5. Step 5.

    Take p95 twice from that file: from the raw durations, then from the per-second averages of the same durations.

    node pct.mjs
    
    samples        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 ms

    p95 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.

  6. Step 6.

    Cut the same run into windows and take p99 of each.

    node window.mjs
    
    n=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.2

    A 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.

  7. 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 LISTENING
    
      TCP    127.0.0.1:9662         0.0.0.0:0              LISTENING       27880
    0

    Read 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

Sign: A report quotes an autocannon p95 that autocannon never printed.Cause: The -l percentile list runs 90, 97.5, 99. There is no 95 row, and neither neighbour stands in for it. On the bimodal route above, p90 was 27 ms and p97.5 was 110 ms, an 83 ms gap that contains the answer. Compute p95 from raw durations, as step 5 does.
Sign: The dashboard p95 is far below the p95 a load test reports.Cause: Most pipelines store one average per interval per instance, then take a percentile of those averages. Step 5 does both on one set of requests: 111.42 ms from the raw durations, 28.02 ms from the per-second means. A percentile of averages is not a percentile.
Sign: p95, p99 and the maximum are quoted together as one finding.Cause: p95 describes the worst request in a page load of 20. p99 describes what a busy user meets several times a day. The maximum describes one event, which may be a garbage collection pause. Here they were 111.42, 126.22 and 136.75 ms. Pick the one that matches the user action you care about.

What to check next

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.

intermediate14 minpublished updated Maks Verny