Resource library

QA How-To

k6 handleSummary HTML Report Tutorial

Build a k6 handleSummary HTML report with runnable code, threshold results, JSON output, verification steps, troubleshooting, and a practical CI artifact gate.

22 min read | 2,843 words

TL;DR

A k6 handleSummary HTML report comes from exporting handleSummary(data) and returning an object such as {"report.html": htmlString}. Build the HTML from aggregate metrics and threshold states, return JSON alongside it, and archive both files even when k6 exits nonzero.

Key Takeaways

  • Export handleSummary(data) and return a filename-to-string map to write an HTML report.
  • Inspect the actual summary JSON before reading trend, rate, counter, or threshold fields.
  • Request p95 in summaryTrendStats if the HTML renderer displays p95 latency.
  • Return both report.html and summary.json for human and machine review.
  • Preserve k6's nonzero threshold exit status while archiving report artifacts.
  • Use a separate time-series output when aggregate metrics cannot explain a spike.

A k6 handleSummary HTML report is an end-of-test artifact you generate from k6's aggregated summary object. Export handleSummary(data) from your test script, turn selected metrics and threshold results into an HTML string, and return an object that maps an output filename to that string. k6 writes the file after the run. The report can then be opened locally or archived by CI.

This tutorial builds a dependency-free report for a small local HTTP test. You will inspect the actual summary shape before formatting it, preserve a machine-readable JSON companion, and make the artifact meaningful when a performance threshold fails. If you are new to the runner, start with k6 load testing fundamentals or performance testing with k6 scripts.

What You Will Build

  • A repeatable k6 HTTP test against a local server, with checks and latency thresholds.
  • A handleSummary(data) function that writes report.html and summary.json from one run.
  • An HTML table with request count, failure rate, p95 latency, and each threshold's pass or fail state.
  • A simple CI gate that checks both the k6 exit status and the presence of the report artifact.

The finished page is intentionally static. It does not pull JavaScript or stylesheets from a CDN. A teammate can download one HTML file from a CI artifact store and read it offline. The accompanying JSON retains the full summary object for later analysis, whereas the HTML highlights only the decisions that matter for this test.

Prerequisites

Install Grafana k6 and verify it with k6 version. This walkthrough uses the classic handleSummary(data) object documented for k6 v2.3.0, the documented version checked when this guide was prepared. Match your installed k6 version when using a package manager or a Docker tag; do not copy an unrelated binary version into your pipeline. The official custom summary reference documents the available fields and notes that an opt-in machine-readable summary format differs from the classic object used here. Leave that opt-in mode disabled for this example.

The reference environment is Grafana k6 v2.3.0 and Python 3.12.7 with its standard-library HTTP server, plus a POSIX-style shell. Check your exact installed versions with the commands below; a maintained Python 3 release with http.server also works for this local fixture. A browser is needed only to inspect the output visually. No npm package, xk6 extension, or hosted reporting service is required.

k6 version
python3 --version

Verify: Both commands print an installed version and return status 0. If k6 is missing, follow the official installation instructions for your operating system, then rerun the version check. Keep a terminal free for the server in Step 1.

Output route What it contains Best use Limitation
handleSummary() HTML Your chosen aggregated metrics and threshold states Human review and archived CI artifact No per-request timeline unless you collect another output
handleSummary() JSON End-of-test summary object Automated analysis of aggregate results Shape can vary by summary format and trend settings
--out json=... Individual metric samples during the run Time-series investigation Larger file, different schema from summary JSON
k6 web dashboard export Dashboard-generated presentation Richer interactive review Separate feature and workflow from this custom HTML

The distinction matters: a summary is an aggregate, not a recording of every request. If you need time-series diagnosis, add a real-time output rather than trying to reconstruct missing samples from handleSummary().

Step 1: Start a Controlled HTTP Target

Create a temporary working directory for the tutorial and serve it on loopback. Run the server in one terminal and leave it running until the last step. A local target keeps the walkthrough independent of public demo-site availability and avoids generating load against an application you do not control.

mkdir -p k6-html-demo
cd k6-html-demo
python3 -m http.server 8000 --bind 127.0.0.1

In a second terminal, move into the same directory and probe the endpoint. Python's directory listing at / is sufficient: the example measures an HTTP 200 response and does not depend on a particular HTML body. Keep the port consistent between the server and the test script.

cd k6-html-demo
curl -I http://127.0.0.1:8000/

Verify: The response starts with HTTP/1.0 200 OK or another successful HTTP version/status combination. If port 8000 is occupied, choose a free port, change both commands and the script URL below, and repeat the probe. A failed curl request is a target setup problem; writing the report code cannot fix it.

Step 2: Write the Baseline k6 Test

Save the following as report.js in k6-html-demo. Two VUs share ten total iterations. The test checks that the server responds with 200, while thresholds separately enforce a low HTTP failure rate, a high check pass rate, and a p95 duration budget. The summaryTrendStats list explicitly requests the percentile keys that the later renderer reads. The 1000 ms budget is illustrative for this local exercise, not a production SLO.

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  vus: 2,
  iterations: 10,
  summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)', 'count'],
  thresholds: {
    http_req_failed: ['rate<0.01'],
    checks: ['rate>0.99'],
    http_req_duration: ['p(95)<1000'],
  },
};

export default function () {
  const response = http.get('http://127.0.0.1:8000/', {
    tags: { name: 'local_home' },
  });
  check(response, { 'home returned 200': (r) => r.status === 200 });
}

Run it from the second terminal:

k6 run report.js

Verify: The command finishes with exit status 0, the console shows ten completed iterations, and each threshold passes. On some systems the displayed summary layout differs between compact and full modes; focus on the metric names and threshold results. If you intend to test an application endpoint later, replace only the URL and the status assertion first. Set budgets from your application's requirements rather than carrying over the local demonstration's 1000 ms figure. For deeper scenario design, see k6 scenarios and executors.

Step 3: Inspect the handleSummary Data Before Formatting It

Append this export to the end of report.js. It returns a filename-to-content map, so k6 writes the supplied JSON string to raw-summary.json. It also sends a short line to stdout; exporting handleSummary() takes control of the end-of-test summary output, so the default formatted console summary is not automatically printed. This is a deliberate inspection pass. The final implementation in Step 4 will replace this function.

export function handleSummary(data) {
  return {
    'raw-summary.json': JSON.stringify(data, null, 2),
    stdout: 'Wrote raw-summary.json for schema inspection\n',
  };
}

Run the test again and inspect three paths with Python, which is already a prerequisite:

k6 run report.js
python3 -c "import json; d=json.load(open('raw-summary.json')); print(d['metrics']['http_req_duration']['values']); print(d['metrics']['http_req_failed']['values']); print(d['metrics']['http_req_duration']['thresholds'])"

Verify: raw-summary.json exists and the command prints a trend-value dictionary containing p(95), a rate-value dictionary containing rate, and a threshold map containing a Boolean ok result. The actual timing numbers vary with your machine. If p(95) is absent, check the summaryTrendStats option before using a different property name. Do not assume that data.metrics.http_req_duration contains individual samples: it holds aggregate values. The Grafana summary reference describes metric types, values, and thresholds.

This inspection step catches a subtle source of broken reports. summaryTrendStats controls which trend statistics appear in values; a script that requests only avg cannot safely render values['p(95)']. Rate metrics use values.rate, while counters such as http_reqs use values.count. A generic formatter must respect those different shapes.

Step 4: Render a k6 handleSummary HTML Report

Replace report.js with the complete version below. The test setup remains identical to Step 2. Helper functions live in the same file, above handleSummary(), and read only the summary object passed to them. metricValue() returns null when a metric or requested value is absent; numberText() then prints n/a instead of manufacturing zero. This distinction prevents an omitted metric from appearing as a perfect result.

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  vus: 2,
  iterations: 10,
  summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)', 'count'],
  thresholds: {
    http_req_failed: ['rate<0.01'],
    checks: ['rate>0.99'],
    http_req_duration: ['p(95)<1000'],
  },
};

export default function () {
  const response = http.get('http://127.0.0.1:8000/', {
    tags: { name: 'local_home' },
  });
  check(response, { 'home returned 200': (r) => r.status === 200 });
}

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

function metricValue(data, metricName, valueName) {
  const metric = data.metrics && data.metrics[metricName];
  const value = metric && metric.values && metric.values[valueName];
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

function numberText(value, digits = 2) {
  return value === null ? 'n/a' : value.toFixed(digits);
}

function thresholdRows(data) {
  const rows = [];
  for (const metricName of Object.keys(data.metrics || {})) {
    const metric = data.metrics[metricName];
    for (const rule of Object.keys(metric.thresholds || {})) {
      const passed = metric.thresholds[rule].ok === true;
      rows.push(`<tr><td>${escapeHtml(metricName)}</td><td>${escapeHtml(rule)}</td><td class="${passed ? 'pass' : 'fail'}">${passed ? 'PASS' : 'FAIL'}</td></tr>`);
    }
  }
  return rows.length ? rows.join('\n') : '<tr><td colspan="3">No thresholds configured</td></tr>';
}

function htmlReport(data) {
  const requests = metricValue(data, 'http_reqs', 'count');
  const failureRate = metricValue(data, 'http_req_failed', 'rate');
  const checkRate = metricValue(data, 'checks', 'rate');
  const p95 = metricValue(data, 'http_req_duration', 'p(95)');
  const requestText = requests === null ? 'n/a' : String(requests);
  const failureText = failureRate === null ? 'n/a' : numberText(failureRate * 100) + '%';
  const checkText = checkRate === null ? 'n/a' : numberText(checkRate * 100) + '%';
  const p95Text = p95 === null ? 'n/a' : numberText(p95) + ' ms';

  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>k6 Local HTTP Report</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; color: #17202a; }
  table { border-collapse: collapse; width: 100%; margin: 1rem 0 2rem; }
  th, td { border: 1px solid #ccd5dd; padding: .65rem; text-align: left; }
  th { background: #f2f6f9; }
  .pass { color: #126c2e; font-weight: 700; }
  .fail { color: #b42318; font-weight: 700; }
</style>
</head>
<body>
<h1>k6 Local HTTP Report</h1>
<p>Aggregated results for the completed test run. Durations are milliseconds.</p>
<h2>Key metrics</h2>
<table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>
<tr><td>HTTP requests</td><td>${escapeHtml(requestText)}</td></tr>
<tr><td>HTTP failure rate</td><td>${escapeHtml(failureText)}</td></tr>
<tr><td>Check pass rate</td><td>${escapeHtml(checkText)}</td></tr>
<tr><td>HTTP duration p95</td><td>${escapeHtml(p95Text)}</td></tr>
</tbody></table>
<h2>Thresholds</h2>
<table><thead><tr><th>Metric</th><th>Rule</th><th>Result</th></tr></thead><tbody>
${thresholdRows(data)}
</tbody></table>
<p>See summary.json for the complete aggregate summary.</p>
</body>
</html>`;
}

export function handleSummary(data) {
  return {
    'report.html': htmlReport(data),
    'summary.json': JSON.stringify(data, null, 2),
    stdout: 'Wrote report.html and summary.json\n',
  };
}

Run the replacement script and open the page:

k6 run report.js
python3 -c "from pathlib import Path; p=Path('report.html'); print(p.exists(), p.stat().st_size if p.exists() else 0)"

Verify: The command prints True and a positive byte count. Open report.html in a browser and confirm the four metrics and three threshold rows appear. raw-summary.json from Step 3 may remain in the directory, but the new run should produce report.html and summary.json. The HTML escapes dynamic metric names and rule text before inserting them into markup. Even when today's rules are constants, keeping that boundary makes future tag names or configured thresholds safe to display.

Step 5: Read the Numbers and Thresholds Correctly

The http_req_duration trend measures request sending, waiting, and receiving. It does not include DNS lookup or connection setup, so a report label should identify the metric rather than claim to show every part of the user's journey. The p95 is a statistic over this run's HTTP requests. With only ten requests, it demonstrates the plumbing but is too small for a serious release decision. See how to read p95 and p99 latency before setting production budgets.

Inspect the HTML's source values and the JSON together. These commands rely on the files created in Step 4 and do not edit the test:

python3 -c "import json; d=json.load(open('summary.json')); print('requests:', d['metrics']['http_reqs']['values']['count']); print('p95 ms:', d['metrics']['http_req_duration']['values']['p(95)']); print('failed rate:', d['metrics']['http_req_failed']['values']['rate'])"
python3 -c "from pathlib import Path; s=Path('report.html').read_text(); print('tables:', s.count('<table>'), 'PASS rows:', s.count('>PASS</td>'))"

Verify: The JSON reports ten requests; the HTML contains two tables and normally three PASS rows. The displayed percentages multiply k6's rate values by 100. A rate of 0.01 means 1%, not 0.01%. Request count is a counter, so it is rendered as an integer-like count instead of being labeled requests per second. If you need throughput, use http_reqs.values.rate and name the time unit explicitly.

Checks and thresholds answer different questions. check() records assertions on individual responses; it does not, by itself, force the process to fail. The checks: ['rate>0.99'] threshold turns that aggregate assertion result into an exit-status gate. The HTTP failure metric follows k6's expected-response handling, which can differ from a custom business assertion. Keep both indicators visible so a technically successful HTTP response with the wrong business data is not silently accepted. The k6 thresholds and checks guide goes deeper on these gates.

Step 6: Prove the Report Survives a Failed Threshold

A useful CI artifact must exist when a performance test fails, not only when it passes. To exercise that path without changing the server, temporarily change the http_req_duration rule in report.js from p(95)<1000 to p(95)<0.000001. This is deliberately impossible for a real HTTP request and is only a local failure demonstration. Keep the rest of the script intact, including handleSummary().

thresholds: {
  http_req_failed: ['rate<0.01'],
  checks: ['rate>0.99'],
  http_req_duration: ['p(95)<0.000001'],
},

Run k6 while capturing its status, then inspect the generated file. Use the second terminal, where the server is still available:

k6 run report.js
printf 'k6 exit status: %s\n' "$?"
python3 -c "from pathlib import Path; s=Path('report.html').read_text(); print('FAIL row present:', '>FAIL</td>' in s)"

Verify: k6 returns a nonzero status for the breached threshold, and report.html still contains a FAIL cell. The exact latency is not important here. The result demonstrates why CI should archive the HTML even after a failed test command. Restore p(95)<1000 afterward, rerun k6 run report.js, and check that the row returns to PASS. This exercise also confirms the threshold table is reading the actual thresholds[rule].ok Boolean rather than inferring success from a green-looking metric.

Step 7: Archive and Validate the Artifact in CI

For a shell-based CI job, keep the runner's exit code while checking the report files. The snippet below works from the directory containing report.js and assumes the local target server is already started by the job. In a real pipeline, replace that fixture with a controlled test environment and arrange startup before the k6 command.

set +e
k6 run report.js
k6_status=$?
set -e
test -s report.html
test -s summary.json
python3 -c "import json; from pathlib import Path; d=json.loads(Path('summary.json').read_text()); h=Path('report.html').read_text(); assert '<!doctype html>' in h.lower(); assert 'http_req_duration' in d['metrics']; print('Report artifacts validated')"
exit "$k6_status"

Verify: A passing test prints Report artifacts validated and exits 0; a threshold failure can still produce both files, after which the shell exits with k6's nonzero code. Configure your CI system to upload report.html and summary.json on both success and failure. The exact artifact-upload syntax depends on your CI provider, so the portable gate above stops at filesystem validation. Avoid k6 run report.js && upload because the upload would be skipped on the failure that most needs diagnosis.

For concurrent jobs, give each job a separate workspace or change the returned output filenames to include a run identifier. handleSummary() writes to the specified paths and overwrites existing files. It does not create a historical archive for you. If the test later runs inside Docker, mount a host directory for the outputs and use a Docker image tag matching your installed k6 version. Check the version inside the container with k6 version before assuming it matches your local development machine.

k6 handleSummary HTML report: Design Choices That Matter

The tutorial uses a small hand-built renderer instead of importing a third-party reporter. That keeps the script executable with the installed k6 binary alone and makes every output field traceable to data.metrics. A dedicated reporter can offer charts, richer layouts, and more metrics, but it adds an external dependency whose version and supported summary schema must be checked against your k6 installation. Choose the approach based on your team's needs, not the visual polish of a screenshot.

The threshold loop is intentionally general: it walks every summary metric and its thresholds map. This means a tagged threshold such as http_req_duration{name:checkout} appears without adding a hard-coded HTML row. The four highlighted numbers remain global metrics. If your test hits multiple routes, a global p95 can conceal a slow checkout behind fast health checks. Add route-specific thresholds and decide which tagged metrics deserve top-level cards. Learn how to build that workload in designing a load model.

Treat the summary as the result of one defined run. Add environment, commit SHA, workload profile, and run timestamp to your artifact metadata from trusted CI inputs when you operationalize it. Do not render a timestamp from the viewer's browser as if it were the test execution time. Use the end-of-test object's documented state fields only after inspecting them for your installed k6 version. A report without workload context is hard to compare across builds.

A failure rate of zero does not prove success if checks cover only HTTP status. Add assertions for response content, relevant business fields, and data integrity where appropriate, then set thresholds on the resulting check rate. Conversely, a failing threshold does not mean the HTML writer broke. The HTML is a diagnostic artifact; the k6 process status remains the automation signal. Keep these roles separate in CI.

Troubleshooting

  • Problem: report.html never appears -> Confirm the script exports handleSummary(data) at top level and returns a map whose key is report.html. Run k6 run report.js from the directory where you expect the file. Also check whether summary generation was disabled with a summary-mode setting; disabled mode prevents handleSummary() from running.
  • Problem: The report shows n/a for p95 -> Inspect summary.json for metrics.http_req_duration.values. Ensure summaryTrendStats contains p(95) and that HTTP requests actually completed. A missing value should remain visibly missing until the underlying metric is fixed.
  • Problem: The console's usual end-of-test summary vanished -> Exporting handleSummary() replaces the default summary output. This tutorial emits a one-line stdout message. If you need the familiar text summary too, use a compatible textSummary helper from the official k6 JS utilities and return its string under stdout after checking that helper's documented version.
  • Problem: The HTML has no threshold rows -> Add rules under options.thresholds, rerun the test, and inspect the thresholds property in summary.json. The renderer explicitly says "No thresholds configured" when there are none; it cannot infer budgets from metric values.
  • Problem: The test cannot connect to 127.0.0.1:8000 in Docker -> Loopback inside the container refers to the container, not the host. Put the server on a reachable network address or run both processes in the same network context, then verify connectivity before running k6.
  • Problem: CI discards the report on a red run -> Preserve k6's exit code as shown in Step 7 and configure artifact upload with your provider's always-run setting. Verify the file exists before upload, then let the job fail with the original k6 status.

Interview Questions and Answers

An interviewer may ask you to explain the contract, not just recite the function name. Prepare to show where the summary comes from, why rate and trend values are read differently, and how a report behaves when a threshold fails. The six model answers in interviewQnA below cover those details; try answering them aloud with your own run's summary.json beside you.

Common Mistakes

  • Treating the aggregate summary JSON as if it were the --out json stream of timestamped samples.
  • Assuming p(95) is always present when summaryTrendStats can change the trend's keys.
  • Rendering missing metrics as zero, which can make a broken run look excellent.
  • Forgetting that handleSummary() replaces the default console summary unless stdout is returned.
  • Putting the artifact upload behind a success-only shell operator.
  • Publishing an HTML file without the workload, environment, and code revision needed for fair comparison.

Where To Go Next

Use this report as a compact decision record for one controlled load run. Expand the test workload with k6 scenarios and executors, set meaningful budgets using k6 thresholds and checks, and diagnose tail behavior with reading percentile latency p95 and p99. If the global duration number hides a particular route, follow finding a performance bottleneck and add tagged metrics or a time-series output.

Conclusion

A k6 handleSummary HTML report is a function result: k6 supplies aggregate data, your script formats it, and the returned filename map determines where it is written. The runnable example produces a readable HTML artifact plus JSON, handles missing metrics honestly, and displays threshold results even when the run fails. Run it locally, inspect the files, then adapt the target and budgets to a workload you own before making it part of a release gate.

Interview Questions and Answers

What does handleSummary(data) receive and return?

It receives an end-of-test object with aggregate metrics, threshold results, and run information. It returns a map from destinations such as report.html or stdout to strings or ArrayBuffers. k6 writes each returned value to its destination after the test run.

How would you render p95 HTTP latency safely?

First ensure summaryTrendStats includes p(95). Then read data.metrics.http_req_duration.values["p(95)"] and verify the value is a finite number before formatting it in milliseconds. If the field is absent, display n/a and inspect the summary JSON rather than substituting zero.

How do checks differ from thresholds in this report?

A check records the outcome of an assertion for each response. A threshold evaluates an aggregate metric such as the checks pass rate or p95 latency against a declared rule and controls test failure status. The report can show both the check rate and the threshold pass or fail decision.

How can a CI job keep a report when k6 exits nonzero?

Capture the k6 exit code without immediately terminating the shell. Validate and upload report.html and summary.json with an always-run artifact step, then exit with the captured code. This preserves the release gate while retaining evidence for debugging.

What is the difference between handleSummary JSON and --out json?

handleSummary JSON is an aggregate snapshot computed after the test, with metric values and threshold states. --out json streams individual metric points during execution. Use the first for a concise test verdict and the second for time-series investigation.

Why might a global p95 be misleading in a multi-route load test?

A fast, high-volume endpoint can dominate the combined HTTP duration distribution and hide a slower critical route. Give requests stable name tags and define route-specific thresholds, then include those sub-metrics in the report. Always state the population behind the percentile.

What security concern exists when generating HTML from metric names?

Metric names and threshold labels may be influenced by configured tags or input data. Escape ampersands, angle brackets, quotes, and apostrophes before inserting text into HTML. Static markup with no external script dependencies also simplifies artifact review.

Frequently Asked Questions

How do I generate an HTML report with k6 handleSummary?

Export handleSummary(data) from your k6 script and return an object whose HTML filename maps to an HTML string. k6 writes the file after the test completes. The Step 4 example also returns summary.json so you can inspect the numbers behind the page.

Does k6 include a built-in HTML report from handleSummary?

handleSummary() provides the summary data and output mechanism; your function or a compatible reporting library supplies the HTML markup. The tutorial creates a static page without an extension. The k6 web dashboard is a separate reporting route.

Why did the normal console summary disappear?

Exporting handleSummary() replaces k6's default end-of-test summary output. Return a string under the stdout key if you want console output. This example prints a short artifact message while saving the detailed result to files.

Can I write HTML and JSON in one k6 run?

Yes. Return multiple entries from handleSummary(data), such as report.html mapped to markup and summary.json mapped to JSON.stringify(data). The files are generated from the same completed run, so their aggregate metrics are consistent.

Why is p95 missing from my k6 summary?

Trend keys depend on summaryTrendStats and on whether samples were collected. Include p(95) in summaryTrendStats, then inspect metrics.http_req_duration.values in the JSON output. Show n/a for a missing value instead of treating it as zero.

Will an HTML report be created if a threshold fails?

A completed run can still call handleSummary() and write its outputs when a threshold fails. The k6 command then returns a nonzero status for the failed gate. CI should preserve that status while uploading the report as an artifact.

Is summary.json the same as k6 --out json output?

No. The JSON returned by handleSummary() contains end-of-test aggregates and threshold states. The --out json output contains individual metric samples with timestamps, which is better for investigating when a slowdown occurred.

Related Guides