How it works
The explanation, with examples you can run.
Why the mean lies
A thousand latencies, 2% of which take a slow path (a cache miss, a lock, a cold connection):
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
The mean looks healthy and so does p95. Only p99 shows that one request in a hundred takes over two seconds. Which percentile you set a goal on depends on how many calls make up one user action: the more calls, the further into the tail you need to look.
What to measure
- Latency percentiles (p50, p95, p99), with successful and failed requests counted separately. A fast error is not a fast success.
- Error rate, including wrong answers that come back with a 200.
- Throughput: requests or completed journeys per second.
- Saturation on the server: CPU, memory, connection pools, queue depth. That is where you find out why latency climbs.
A k6 script with a ramp and thresholds
// load.js: run with k6 run -e BASE_URL=https://staging.example.com load.jsimport http from "k6/http"import { check, sleep } from "k6" export const options = { stages: [ { duration: "2m", target: 50 }, // ramp up to 50 virtual users { duration: "5m", target: 50 }, // hold { duration: "1m", target: 0 }, // ramp down ], thresholds: { http_req_failed: ["rate<0.01"], // under 1% errors http_req_duration: ["p(95)<300", "p(99)<800"], // milliseconds },} export default function () { const res = http.get(`${__ENV.BASE_URL}/api/products?page=1`) check(res, { "status is 200": (r) => r.status === 200 }) sleep(1) // think time between a user's actions}If a threshold fails, k6 exits with a non-zero code, so a CI job fails the same way it would for a broken unit test.
Closed and open load
Virtual users that wait for each response form a closed model: when the server slows down, they send less, and the test eases off exactly when it should push. To hold a fixed arrival rate whatever the response times, use an open model:
export const options = { scenarios: { checkout: { executor: "constant-arrival-rate", rate: 100, timeUnit: "1s", // 100 new iterations every second duration: "5m", preAllocatedVUs: 50, maxVUs: 300, }, },}Making results mean something
Test an environment sized like production, with realistic data volumes (an empty database is fast), and a realistic mix of requests rather than one URL. Start with a smoke run at minimal load to check the script works. Watch the system under test, not just the load generator. A load generator that runs out of CPU produces numbers about itself, not about your service.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.