The metrics k6 always reports
Every k6 run produces these built-in metrics:
| Metric | Type | What it measures |
|---|---|---|
http_req_duration | Trend | Full round-trip time from request sent to body received |
http_req_waiting | Trend | Time to first byte (TTFB) — server processing time |
http_req_sending | Trend | Time to send the request body |
http_req_receiving | Trend | Time to download the response body |
http_req_failed | Rate | Fraction of requests that returned an error |
http_reqs | Counter | Total requests sent |
vus | Gauge | Active virtual users right now |
vus_max | Gauge | Max VUs allocated during the test |
iteration_duration | Trend | Time for one full VU iteration |
data_received | Counter | Total bytes downloaded |
data_sent | Counter | Total bytes uploaded |
Trend metrics: never look at the average
http_req_duration is a Trend — k6 collects every sample and reports:
http_req_duration..............: avg=142ms min=89ms med=134ms max=2103ms p(90)=201ms p(99)=890ms
The average is misleading. A few very slow requests inflate it, masking the fact that 90% of users had a fast experience. Or the opposite — a bimodal distribution with half your users at 50ms and half at 2000ms averages out to 1025ms, hiding the problem entirely.
The percentiles that matter
| Percentile | What it tells you |
|---|---|
p(50) / median | Typical experience |
p(90) | 9 out of 10 users are faster than this |
p(95) | Common SLO boundary |
p(99) | The "tail" — your worst regular experience |
p(99.9) | Rare edge cases — only relevant at very high volume |
Rule of thumb: Set your SLO on p95 or p99, not average.
Setting thresholds
Thresholds turn a load test into a pass/fail gate:
export const options = {
thresholds: {
// p99 latency under 500ms
http_req_duration: ['p(99)<500'],
// error rate under 1%
http_req_failed: ['rate<0.01'],
// at least 100 req/s throughput
http_reqs: ['rate>100'],
// only for the /api/checkout endpoint
'http_req_duration{url:https://api.example.com/checkout}': ['p(95)<300'],
},
};
Threshold syntax
metric_name[{tag:value,...}]: ['aggregation_function operator value']
Available aggregation functions: avg, min, max, med, p(n), count, rate, value
Custom metrics
You can define your own metrics and track business logic:
import { Trend, Counter, Rate, Gauge } from 'k6/metrics';
const checkoutLatency = new Trend('checkout_duration');
const cartErrors = new Counter('cart_errors');
const loginSuccessRate = new Rate('login_success');
export default function () {
const start = Date.now();
const res = http.post('/api/checkout', payload);
checkoutLatency.add(Date.now() - start);
if (res.status !== 200) {
cartErrors.add(1);
}
loginSuccessRate.add(res.status === 200);
}
Custom metrics appear in the output alongside built-in ones, and you can use them in thresholds too.
What to ignore
http_req_blocked— DNS lookup + TCP connect time. Only interesting if you're testing from a cold start or see values > 10ms consistently.http_req_tls_handshaking— TLS setup time. Usually < 50ms and amortised across keep-alive connections.data_received/data_sent— Useful for capacity planning, not SLO validation.
The one metric dashboard you need
In Grafana, visualise:
- RPS (
http_reqsrate) — is the load hitting the target? - p99 latency (
http_req_durationp99) — is the service staying fast? - Error rate (
http_req_failedrate) — is anything breaking? - Active VUs (
vus) — confirms the ramp-up is working
These four panels tell you everything you need to make a pass/fail decision.