Skip to main content

k6 Reports: Summary, JSON, CSV and HTML Dashboard

k6 report formats tested on v2.3.0: the end-of-test summary, JSON and CSV output, handleSummary, and the HTML dashboard export k6 skipped on a 19-second run.

k6 Reports: Summary, JSON, CSV and HTML Dashboard

Locally, without any external service, k6 reports its results in five ways: the end-of-test summary it prints to the terminal, a JSON file of that summary (--summary-export), a line-by-line stream of every metric sample (--out json or --out csv), an HTML report exported from its built-in web dashboard, and whatever your own handleSummary() function writes. Each answers a different question, and each has a default that surprises people.

We ran the same 35-second, 20-user test twice on k6 v2.3.0, through the summary, the export, JSON, CSV and the dashboard, plus shorter 5-user runs for handleSummary(), gzip, p(99) and the dashboard’s cut-off. Three results surprised us: the HTML report was skipped on a 19-second run with only a warning and written on a 20-second one, the summary export marks a threshold that passed as false, and CSV timestamps come in whole seconds unless you ask for more.

Key Takeaways

  • k6 v1.0 revamped the terminal summary and gave it modes. In v2.3.0 the default is compact, which drops the per-group breakdown and all six http_req_* timing phases; --summary-mode=full brings them back.
  • For an HTML report, set K6_WEB_DASHBOARD=true and K6_WEB_DASHBOARD_EXPORT=report.html. Our export was a single 168 KB file, but at the default 10-second period every run we made under 20 seconds got no file, and the skip itself only logs a warning: it does not fail the run.
  • --out json wrote about 3 KB per request in our test. Name the file .json.gz and k6 gzips it: a 5-second run went from 373 KB to 16 KB.
  • In the default --summary-export format, a threshold value of false means it did not fail. p(99) appears only if you add it with --summary-trend-stats.
  • handleSummary() replaces the terminal summary entirely. In our run with a failing threshold, handleSummary() still ran, the file was written and k6 exited 99.

Which k6 report format should you use?

Use the terminal summary to read one run, the HTML dashboard export to share it with people, --out json or --out csv to analyse individual requests after the test, and handleSummary() or --summary-export when a CI job has to read the numbers. The table shows what each gave us for one run of our test: 2,532 requests from 20 virtual users, with two groups, checks, three thresholds and a custom metric.

ReportHow you turn it onWhat you getSize in our runWatch out for
End-of-test summaryOn by defaultThresholds, checks, metric stats in the terminal32 non-blank lines (compact), 73 (full)Compact mode hides groups and timing phases
Summary export--summary-export=summary.jsonThe summary’s numbers as JSON6 KBfalse on a threshold means it passed
JSON output--out json=out.jsonOne line per metric sample, with tags7.8 MB, 32,160 linesGrows with every request
CSV output--out csv=out.csvThe same samples as CSV rows3.1 MB, 32,143 linesTimestamps in whole seconds by default
HTML dashboard exportK6_WEB_DASHBOARD=true K6_WEB_DASHBOARD_EXPORT=report.htmlA self-contained HTML report, with charts on runs longer than three periods168 KBSkipped on short runs
handleSummary()An exported function in the scriptAny files or text you build from the summary dataYour choiceReplaces the terminal summary

What does the k6 end-of-test summary show?

The summary shows threshold results first, then checks, then the metrics grouped as custom, HTTP, execution and network, each trend with avg, min, med, max, p(90) and p(95). In k6 v2.3.0 the default compact mode prints only the main HTTP metrics and skips per-group sections. --summary-mode=full adds a block for every group and the http_req_* timing phases.

This is the compact summary from a first run of the test (the table’s figures come from a second run, which also wrote the files), trimmed to the parts most people read:

  █ THRESHOLDS

    checks
    ✓ 'rate>0.97' rate=98.12%

    http_req_duration{name:slow}
    ✓ 'p(95)<250' p(95)=230.45ms

    http_req_failed
    ✓ 'rate<0.05' rate=2.50%

  █ TOTAL RESULTS

    ✓ login 200
    ✓ has token
    ✓ slow 200
    ✗ flaky 200

    HTTP
    http_req_duration..............: avg=54.7ms   min=120µs    med=21.07ms  max=242.51ms p(90)=181.41ms p(95)=209.74ms
      { expected_response:true }...: avg=55.56ms  min=120µs    med=21.06ms  max=242.51ms p(90)=182.75ms p(95)=210.09ms
      { name:slow }................: avg=142.27ms min=40.68ms  med=144.09ms max=242.51ms p(90)=220.86ms p(95)=230.45ms
    http_req_failed................: 2.50%  63 out of 2520
    http_reqs......................: 2520   71.405446/s

    EXECUTION
    vus............................: 2      min=2          max=20

Under the failed check, k6 prints a breakdown line: 92% passed, 777 succeeded and 63 failed. Failed checks alone never change the exit code; k6 checks and their thresholds covers how to make them count. Three lines deserve a closer look. The vus line starts with the last value sampled, here 2 during the ramp-down, not the peak; the peak is the max. http_req_duration includes failed requests, while the { expected_response:true } line below it covers only the successful ones. And a tag filter used in a threshold, such as {name:slow}, gets its own line, which is a cheap way to see one endpoint’s numbers without a separate output.

The second run, with --summary-mode=full, printed a GROUP: login block and a GROUP: browse block, each with its own checks and HTTP metrics. That split showed every failed check came from the browse group (95.73% passed there, 100% in login), something the compact view leaves you to work out from check names. --summary-mode=disabled turns the end-of-test summary off.

How do you get a k6 HTML report?

Turn on the built-in web dashboard and ask it to export: K6_WEB_DASHBOARD=true K6_WEB_DASHBOARD_EXPORT=report.html k6 run test.js. While the test runs, the dashboard is served at 127.0.0.1:5665; when it ends, k6 writes the report to the file. Ours was one self-contained 168 KB HTML file; the only URLs in it were XML namespace identifiers, so it opens offline and can be attached to a ticket.

The catch is the length of the run. An earlier export, on an 18-second version of the test, produced no file, and the log said only:

level=warning msg="The test run was short, report generation was skipped (not enough data)"

That run’s exit code was 0, so a CI job would not have noticed. We narrowed it down with the same script at 5 virtual users:

Test lengthDashboard periodReport written?
15 s10 s (default)No
17 s10 sNo
19 s10 sNo
20 s10 sYes
3 s2 sNo
8 s2 sYes

The pattern fits a rule that the test must last at least two of the dashboard’s aggregation periods. That is our reading of these runs, not something the warning states. Grafana’s documentation adds a second limit: the report includes graphs only when the test lasts more than three times the aggregation period, so more than 30 seconds at the default. If you need a report from short smoke tests, set K6_WEB_DASHBOARD_PERIOD lower, for example 2s, and check in CI that the file exists.

Some guides build an HTML page inside handleSummary() with a community module instead. The built-in dashboard export needs no extra code, and it is the one we tested.

How do you export k6 results to JSON or CSV?

Add an output with --out: --out json=out.json writes one JSON object per line, and --out csv=out.csv writes one row per metric sample. Both record every sample of every metric, tagged with the group, request name, method, status and scenario, so you can rebuild any statistic afterwards. Several --out flags can run at once.

Each metric gets one Metric line, written just before its first sample, and every sample is a Point line:

{"metric":"http_reqs","type":"Point","data":{"time":"2026-11-06T15:26:35.397813+02:00","value":1,"tags":{"expected_response":"true","group":"::login","method":"POST","name":"http://127.0.0.1:18089/login","proto":"HTTP/1.1","scenario":"default","status":"200","url":"http://127.0.0.1:18089/login"}}}

Our 2,532 requests produced 32,142 points in 7.8 MB (the table’s 32,160 lines add one Metric line for each of the 18 metrics), about 3 KB per request, because each request writes its duration, its six timing phases, a failure flag and a count, alongside check, group and iteration points. At that rate a million requests is around 3 GB. Naming the file out.json.gz made k6 gzip it: a 5-second run wrote 373 KB plain and 16 KB compressed.

The CSV file holds the same samples with tags as columns:

metric_name,timestamp,metric_value,check,error,error_code,expected_response,group,method,name,proto,scenario,service,status,subproto,tls_version,url,extra_tags,metadata
http_req_duration,1793971595,0.297000,,,,true,::login,POST,http://127.0.0.1:18089/login,HTTP/1.1,default,,200,,,http://127.0.0.1:18089/login,,

The timestamp is a Unix time in whole seconds, so every request in the same second sorts as a tie. Set K6_CSV_TIME_FORMAT to unix_milli, unix_micro, unix_nano or rfc3339_nano for sub-second timestamps; rfc3339 gives a date string, still in whole seconds. We ran all five. An unknown value such as bogus logs could not create the 'csv' output: bogus does not belong to TimeFormat values and writes no CSV.

What is the difference between —summary-export and handleSummary?

--summary-export writes the end-of-test summary’s numbers to a JSON file and leaves the terminal summary alone. handleSummary() is a function you export from the script: k6 passes it the same summary data and writes whatever it returns, to files or to stdout, in place of the normal summary. Use the flag for a quick file, the function when you need your own format.

Two details of the export file trip up scripts that read it. Thresholds are stored as booleans that answer “did it fail?”, so our passing checks threshold appears as:

"checks": {"passes": 3304, "fails": 72, "thresholds": {"rate>0.97": false}, "value": 0.9786729857819905}

And trend metrics carry only the stats the summary prints. There was no p(99) in our file until we ran with --summary-trend-stats="avg,med,p(95),p(99),max", which added it to both the terminal and the export.

A minimal handleSummary() that writes the data to a file looks like this:

export function handleSummary(data) {
  return { 'summary.json': JSON.stringify(data, null, 1) };
}

With only that, the terminal printed nothing at the end of the run, as Grafana’s documentation says: when handleSummary() is exported, k6 does not print the default summary. The documented way to keep it is to also return stdout: textSummary(data), importing textSummary from the k6-summary module on jslib.k6.io; we did not run that remote module. When we made a threshold fail, k6 logged thresholds on metrics 'http_req_duration' have been crossed, still called handleSummary(), wrote the file and exited with code 99.

The data passed to handleSummary() differs from the export in several ways: the stats sit under values, each metric carries its type, and options and state are added at the top level. The most useful difference is that each threshold reads {"ok": true} instead of a bare false. With --new-machine-readable-summary, both the export and the function’s argument switch to a newer format with config, metadata, results and version (1.0.0 in v2.3.0) at the top level, so code written for one format will not read the other.

How do you read k6 results on LoadFocus?

LoadFocus runs your k6 script on cloud machines and builds the report for you, so none of the output flags above are needed for a cloud run. The results page has Overview, AI Assist, Timeline, Trend, Insights, Errors, Stats, Logs and Notes tabs. You can print the report to PDF, which includes a 50th, 90th, 95th and 99th percentile breakdown, or share it through a public link.

The guide to analysing k6 test results lists what each tab shows (Timeline is not on the Free plan). An AI analysis of a finished run identifies bottlenecks and explains performance patterns, and the Trend tab shows how the test performed across its recent runs. Tests can run from any of 25 cloud locations (several at once on the Pro and Enterprise plans), with up to 20,000 concurrent virtual users, according to the k6 load testing page. The local outputs in this guide remain the right tool when you run k6 on your own machine or in CI.

Frequently Asked Questions

How do I generate an HTML report in k6?

Run the test with two environment variables set: K6_WEB_DASHBOARD=true and K6_WEB_DASHBOARD_EXPORT=report.html. k6 writes the HTML file when the test ends. In our runs, tests shorter than two dashboard periods (under 20 seconds at the default 10-second period) got no report, only a warning, and Grafana’s docs say charts appear only past three periods.

Why is my k6 HTML report not generated?

Usually because the test was too short. k6 v2.3.0 logs “The test run was short, report generation was skipped (not enough data)”, and the skip does not fail the run: our 18-second run exited 0. In our runs a 19-second test got no report and a 20-second test did. Lower the period with K6_WEB_DASHBOARD_PERIOD, or run longer.

Why does the k6 summary not show my groups?

Because the default summary mode in k6 v2.3.0 is compact, which shows totals only. Run with —summary-mode=full to get a separate block for every group, with its own checks and HTTP metrics, plus the timing phases of each request.

Does handleSummary run when a k6 threshold fails?

Yes. In our test, a failed threshold made k6 log that the threshold was crossed, call handleSummary, write the file it returned and then exit with code 99. That makes handleSummary usable for CI artifacts on runs that fail a threshold.

How do I add p(99) to the k6 summary?

Pass —summary-trend-stats with the list you want, for example “avg,med,p(95),p(99),max”. The same list is used by the terminal summary and by —summary-export, so the exported JSON gains a p(99) value for every trend metric too.

Sources

  • End-of-test summary, summary modes, results output, the web dashboard and its environment variables, and handleSummary: Grafana k6 documentation, grafana.com/docs/k6/latest/results-output/, read 6 November 2026.
  • k6 v2.3.0, released 21 September 2026: github.com/grafana/k6/releases, read 6 November 2026.
  • LoadFocus k6 results and limits: Analyzing k6 Load Test Results (loadfocus.com/docs/guides/k6-load-testing/analyzing-k6-test-results) and loadfocus.com/k6-load-testing, read 6 November 2026.
  • Test runs: k6 v2.3.0 (official macOS arm64 release, checksum-verified) against a local Node.js test API with a login, a 40-240 ms endpoint and an endpoint failing one request in twelve: two runs of 20 virtual users for 35 seconds, plus 5-user runs of 3 to 30 seconds for the dashboard cut-off, handleSummary, gzip and p(99), on an Apple M1 Max, 6 November 2026. Sizes and timings show patterns, not benchmarks.

Bogdan

Founder at LoadFocus

Bogdan builds and runs the tools this blog is about. He writes from what the products actually do in production, including the parts that break.

Weekly tips right in your inbox.

Don’t miss out on the latest testing tips and tricks, tools and news related to software testing.

Already +20,000 members in the list.

The Load Testing Platform with AI That Explains Your Results

Load test websites and APIs from 25 cloud regions, monitor page speed and uptime, and get AI analysis that explains your results in plain English.

Cloud load testing, website speed and API monitoring in one place.

Hi there! Can I share a cool thing I'm working on with you?

A software testing newsletter that sparks joy.

Our goal is to create helpful content for software developers and testers.

If you're not into it, you can unsubscribe instantly.