Reading load test results correctly: understanding the main metrics such as VU count, latency, errors, and the p95 and p99 percentiles, then optimizing k6 scripts with http.batch, discardResponseBodies, and expectedStatuses, and structuring thresholds that target SLAs or SLOs.

After episode 12, your tests are safe, honest, and runnable without making the infra team angry. But running a test is only half the story. The real question appears once the numbers show up on screen: what do all these numbers mean, and is your application healthy?
A doctor doesn't treat a patient based on a single blood pressure reading — they look at pulse, temperature, oxygen levels, and how they move over time. Similarly, a load test: a single average number can deceive. This episode 13 covers two mutually supporting sides: how to read the results (which metrics matter, and why p95 is more honest than the average), and how to make the script not lie — because a test engine too busy with JavaScript produces data that measures k6 instead of your application.
The k6 end-of-test summary contains a series of metrics that often confuse beginners. Here are the most important ones to make friends with:
| Metric | Content | What you should think about |
|---|---|---|
vus | number of active virtual users | whether it approaches the planned target |
http_req_duration | total duration of one request | the main latency metric, look at avg, med, p(90), p(95) |
http_reqs | total number of requests + rate per second | whether throughput meets the target |
http_req_failed | proportion of failed requests (4xx/5xx status, connection errors) | the error rate agreed in your SLO |
http_req_blocked | time waiting for a slot to open a connection | swells if connections are constantly reopened |
http_req_connecting | TCP + TLS handshake duration | measures network overhead, not the application |
The http_req_duration metric is the star, and within it k6 displays several statistics at once: avg, med, min, max, p(90), p(95). An example summary:
Notice how honest the first line is: the average is 148.2 ms, the median 120.5 ms, but there are spikes up to 1.42 s, and 5 percent of requests took more than 520.9 ms. If you only look at the average, you conclude the application is healthy. Percentiles expose the reality: there is a portion of users experiencing a much worse experience.
This is the most important statistics lesson in performance testing. The average can hide a long tail: 99 requests complete in 100 ms and one request completes in 10 seconds, producing an average of ~200 ms — looks good, even though there's a user waiting 10 seconds.
Percentiles tell a different story:
p(95) — 95 percent of requests complete within this value. The remaining 5 percent, however long they take, aren't included in this number.p(99) — 99 percent of requests complete within this value. This is closest to the experience of the most unlucky users.Percentile consistency is a far stronger stability indicator. If p(95) stays stable while avg rises, there may be a few outliers that don't represent the majority. If p(95) jumps, the majority of users are genuinely affected. This is why a good SLA reads "95 percent of requests complete under 300 ms", not "the average request is under 300 ms".
k6 executes JavaScript on top of the Go runtime (Sobek). Every unnecessary job the script does is a cost mixed into the metrics — and if the test engine runs out of resources, the test measures the test engine, not the application. The four most impactful techniques:
1. Reduce JavaScript overhead. Move computations that can be calculated once into setup() or module scope. Repeatedly parsing JSON for the same data, or building new header objects in every iteration, is garbage that slows down each VU. Calculate once, store, reuse.
2. Use http.batch() for parallel requests. If a page loads several independent endpoints, don't call them one by one sequentially — that artificially extends the iteration duration. http.batch() sends them in parallel within a single iteration:
import http from "k6/http";
import { check } from "k6";
const responses = http.batch([
["GET", "https://api.example.com/v1/user"],
["GET", "https://api.example.com/v1/orders"],
["GET", "https://api.example.com/v1/notifications"],
]);
for (const [name, res] of [
["user", responses[0]],
["orders", responses[1]],
["notifications", responses[2]],
]) {
check(res, {
[`${name} responds 200`]: (r) => r.status === 200,
});
}http.batch() also reduces overhead: requests are sent together and the results are returned as an array. The default batch allows 20 parallel connections per call, enough for almost all cases.
3. Enable discardResponseBodies. This is the optimization with the biggest impact and the least effort. If you don't need to read the response body, k6 still stores it in memory — pushing the GC to work harder. Turn it off with one option:
export const options = {
vus: 50,
duration: "5m",
discardResponseBodies: true,
};For requests that genuinely need their body, set responseType: "text" per request. The result: test memory drops drastically, the GC is rarely triggered, and latency metrics aren't contaminated by the test engine's extra work.
4. Use http.setResponseCallback. By default k6 treats 2xx and 3xx statuses as success. If your application has custom success status codes (e.g., 202 Accepted for async jobs), set a callback at the options level:
import http from "k6/http";
export const options = {
vus: 50,
duration: "5m",
setResponseCallback: http.expectedStatuses(200, 201, 202),
};With this, http_req_failed only counts statuses outside the list — the error metric becomes accurate, and the thresholds you build stand on the right foundation.
Good numbers are meaningless without an agreed limit. A threshold is the boundary line that makes k6 fail explicitly when the application violates the target — this is what turns a load test from "looking at numbers" into "acceptance checking". Structure thresholds based on your SLO:
export const options = {
vus: 100,
duration: "10m",
thresholds: {
http_req_duration: ["p(95)<300", "p(99)<800"],
http_req_failed: ["rate<0.01"],
http_reqs: ["rate>50"],
},
};"p(95)<300" — 95 percent of requests complete under 300 ms: the primary latency SLO."p(99)<800" — 99 percent under 800 ms: a safety net for the most unlucky users."rate<0.01" — error rate below 1 percent."rate>50" — minimum throughput of 50 requests per second.If a threshold is violated, k6 exits with a non-zero exit code — and CI/CD can reject the release immediately. For extra load control, add { abortOnFail: true, delayAbortEval: "30s" } so the test stops early when a threshold is certain to fail, without waiting for the full duration.
avg — it hides the long tail. Look at p(95) and p(99).http_req_failed — a 1 percent error on 100 thousand requests is 1,000 failed users.In this episode 13 you've been able to read test results like a professional: understanding the key metrics (vus, http_req_duration, http_reqs, http_req_failed, http_req_blocked, http_req_connecting), seeing why the p95 and p99 percentiles are more honest than the average, optimizing scripts with reduced JavaScript overhead, http.batch(), discardResponseBodies, and http.setResponseCallback(http.expectedStatuses(...)), and structuring thresholds that target SLAs and SLOs — complete with abortOnFail for early termination.
Key points to take away:
p(95) and p(99), not just avg.discardResponseBodies: true for tests that read rather than process bodies.A terminal summary is enough for one test. But what if you run ten tests per day, want to see weekly trends, and share dashboards with the team? That needs real observability. In episode 14 we'll cover Observability, Tracing & Monitoring — integrating k6 with Grafana, InfluxDB, and Prometheus, using external output, and custom metrics and tagging for deeper analysis. See you in episode 14!