
Product news and testing tips.
k6 thresholds answer one question — did this run meet a fixed limit — and exit 99 when they fail. What open-source k6 does not do is store results or compare runs; that is a Grafana Cloud k6 capability. `handleSummary()` is the escape hatch: it receives the aggregated summary and can write any number of files, including JUnit XML via Grafana's own jslib helper.
Key takeaways
- A failed threshold makes k6 exit with code 99, distinct from an ordinary error.
- `handleSummary()` returns a map of destination to content and can write several files in one run.
- Grafana publishes a jUnit() helper in k6-jslib-summary — that is the official JUnit path.
- Cross-run comparison is documented as a Grafana Cloud k6 feature; open-source k6 has no results store.
- k6 v2.0.0 removed --no-summary and the legacy summary mode, so older CI recipes need updating.
k6 is unusually good at the thing it does. You write a script, define thresholds, run it, and get a pass or fail plus a summary that is genuinely readable. For gating a pull request on “did latency stay under 200ms”, it is close to ideal.
The gap appears the second time you run it. A load test’s most useful signal is almost never the absolute number — it is the delta. p95 at 190ms passes a p(95)<200 threshold and is also a 40% regression from last week’s 135ms, and the threshold cannot tell you that.
Thresholds: what k6 gates on
export const options = {
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<200', 'p(99)<500'],
checks: ['rate>0.99'],
},
};
Two behaviours worth knowing exactly.
A failed threshold produces exit code 99. Not 1 — 99 specifically, which k6 defines as ThresholdsHaveFailed. That matters because it is distinguishable from a script error or a network failure, so a CI step can tell “the test ran and the system was too slow” apart from “the test did not run”. k6 uses several such codes; 104 is an invalid config, for instance.
abortOnFail stops the run early:
thresholds: {
http_req_duration: [{ threshold: 'p(95)<200', abortOnFail: true, delayAbortEval: '10s' }],
},
delayAbortEval is the important companion — without it, evaluation begins immediately and a cold start or a JIT warm-up can trip the threshold in the first second on a test that would have been fine.
Thresholds are the right mechanism for a hard gate — they are a quality gate in the ordinary sense, expressed in k6’s own vocabulary. They are also, by construction, a comparison against a number you typed in.
Getting results out: handleSummary()
Export a function called handleSummary and k6 hands you the aggregated summary to do what you like with:
import { jUnit } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
export function handleSummary(data) {
return {
'stdout': textSummary(data, { indent: ' ', enableColors: true }),
'junit.xml': jUnit(data),
'summary.json': JSON.stringify(data, null, 2),
};
}
The contract: one argument in (the summary data), a map out whose keys are destinations — stdout, stderr, or any file path — and whose values are strings or ArrayBuffers. It can write as many files as you want in a single run.
One trap: exporting handleSummary suppresses k6’s default end-of-test summary. If you want console output too, include a stdout key, which is what textSummary is for. Plenty of people add JUnit output and accidentally silence their own logs.
That jUnit() helper is the closest thing to an official JUnit path — there is no --out junit. It comes from k6-jslib-summary, which is a Grafana-org repository, and the docs ship this exact pattern. What lands is ordinary JUnit XML with its usual limits.
Native output formats
For streaming rather than end-of-test:
| Output | Status |
|---|---|
--out json=file.json | Current |
--out csv=file.csv | Current |
--out cloud | Current — Grafana Cloud k6 |
--out opentelemetry | Current |
-o experimental-prometheus-rw | Still experimental |
| StatsD | Removed in v0.55.0 |
The StatsD removal catches people migrating an old pipeline: it was deprecated in v0.47.0 and is now gone, not merely discouraged.
A v2 warning for anyone copying an older recipe. k6 v2.0.0 removed --no-summary — use --summary-mode=disabled — and removed the legacy summary mode entirely, so the end-of-test summary renders as compact (default) or full and does not match the block you may remember. Also note k6 runs two active lines: v1.8.1 shipped after v2.2.0, so “latest” is ambiguous and your pipeline should pin deliberately.
The metrics you actually compare
k6 has four metric types — Counter, Gauge, Rate, and Trend — and the built-ins worth trending are:
| Metric | Type | What it tells you |
|---|---|---|
http_req_duration | Trend | Latency distribution |
http_req_failed | Rate | Error rate |
http_reqs | Counter | Throughput |
iteration_duration | Trend | Full user-journey time |
Trend metrics summarise as avg, min, med, max, p(90), p(95). The percentiles are the ones that matter; an average latency is a number that hides exactly the users having a bad time.
Where open-source k6 stops
This is the honest boundary, and it is worth being precise rather than vague about it.
Grafana Cloud k6 documents cross-run comparison: comparing a run against a starred baseline, comparing two selected runs, and comparing across all runs of a script. That is a real, documented capability.
Open-source k6 has no results database. It emits output — stdout, JSON, CSV, OpenTelemetry, a streaming backend you provision — and then the process exits. There is no store, so there is nothing to compare against. Any trend view you get from OSS k6 comes from wherever you sent the data, not from k6.
To be fair to Grafana: I could not find a docs page that states the OSS limitation in those words. The conclusion comes from what the two products document — Cloud documents comparison, OSS documents outputs and no storage layer — rather than from a disclaimer anyone wrote.
Practically, this leaves three options:
- Grafana Cloud k6, if the comparison features are what you want and the pricing works.
- Stream to your own backend — Prometheus, InfluxDB, OpenTelemetry — and build the trend panels yourself. Most control, most setup.
- Emit a per-run artifact via
handleSummaryand let something downstream accumulate them.
A CI setup
- name: Load test
run: |
k6 run --summary-mode=full script.js
# exit 99 = thresholds failed; the step fails, which is what you want
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-results
path: |
junit.xml
summary.json
The if: always() matters more than usual here, because a threshold breach is precisely the run whose numbers you want to keep. Other GitHub Actions reporting limits apply as they do to any framework.
Why this matters more for load tests
Functional tests are mostly binary — a test passes or it does not, and a single run tells you most of what you need. Performance is a distribution, and a distribution is only meaningful against a baseline.
“p95 was 190ms” is not information. “p95 was 190ms, against 135ms last Tuesday, on the same script and roughly the same data volume” is. That gap between a single result and a result in context is the whole case for test observability, and it applies to performance work more sharply than to functional testing. A threshold is a fixed line, and a slow regression can drift underneath it for months, passing every build, until the day it does not — at which point you learn about it as an incident rather than as a trend.
Our stake in this, stated up front: Qualflare is ours, and keeping per-run results so they can be compared is precisely what it does. It does not run load tests and it is not a k6 replacement — the script, the thresholds and the execution all stay k6’s. It also is not the only answer to this: if you already run Prometheus or Grafana, streaming k6 output there and building a panel is a perfectly good solution and probably cheaper. The point of this post is the gap, not any particular way of filling it.
Frequently asked questions
How do you fail a CI build on k6 results?
Define thresholds in the options object — for example http_req_duration: ['p(95)<200'] — and k6 exits with code 99 when any threshold fails, which fails the step. Add abortOnFail: true to a threshold to stop the run as soon as it breaches rather than completing, and delayAbortEval to let samples accumulate before evaluation begins.
How do you export k6 results to JUnit XML?
There is no built-in JUnit output. The official route is the handleSummary() function plus the jUnit() helper from Grafana’s k6-jslib-summary library — import it, then return { 'junit.xml': jUnit(data) } from handleSummary. Because handleSummary returns a map, you can emit JUnit XML, a JSON summary and console output from the same run.
Does k6 compare results between runs?
Not in the open-source tool. Grafana Cloud k6 documents three ways to compare test runs — against a starred baseline run, between two selected runs, and across all runs of a script. Open-source k6 emits per-run output to stdout, JSON, CSV or a streaming backend, and keeps no results database of its own, so any trend view has to come from wherever you send that data.
What does handleSummary receive and return?
It receives one argument, the aggregated summary data object for the run. It returns a map whose keys are destinations — stdout, stderr, or any file path — and whose values are strings or ArrayBuffers. Exporting it suppresses k6’s default end-of-test summary, so include a stdout entry if you still want console output.
What changed in k6 v2?
Two changes matter for CI recipes. --no-summary was removed in favour of --summary-mode=disabled, and the legacy summary mode was removed, so the end-of-test summary now renders in compact (the default) or full and does not match the older format. Note that k6 maintains two active lines — v1.8.1 shipped after v2.2.0 — so check which one your pipeline pins.


