k6
Every load-testing tool answers the same question — what does this system actually do under traffic nobody's applying yet — but k6 answers it in one specific, opinionated way: a test is a JavaScript file, not an XML export from a GUI recorder, so it lives in the same repository as the service it tests, gets the same pull-request review, and runs in the same CI pipeline as the unit tests sitting next to it. This page covers that developer-centric model end to end — the virtual-user and iteration mechanics that shape a load profile, the threshold syntax that turns a load test into a pass/fail gate a pipeline can enforce with zero extra scripting, and the real-time reporting path into Prometheus-compatible storage, Grafana dashboards, and Grafana Cloud k6 that makes a long-running test watchable while it's still in flight.
Picture handing homework to a robot instead of a person. A person "load testing" a website might click around by hand for a bit and say "seems fine" — vague, not repeatable, and nobody can rerun it automatically before every single deploy. k6 is that homework written as a program instead: "send fifty pretend shoppers to this checkout page for five minutes, and if the 95th-slowest response takes longer than four hundred milliseconds, or more than one in a hundred orders fails, the answer is FAILED — not vague, a straight yes or no." Because it's a program and not a person clicking around, a computer can run that exact homework automatically every time new code is about to ship, and refuse to ship it the moment the answer comes back FAILED.
What k6 is and the problem it solves
☺ Like you're 10: It's one small program that pretends to be a crowd of users by following a script you wrote in JavaScript, and it's built to say PASS or FAIL on its own, with nobody watching.
k6 began life at Load Impact, a Stockholm-based load-testing company, and was open-sourced as its own project before Grafana Labs acquired it outright on June 17, 2021, folding it into the Grafana observability stack alongside Prometheus, Loki, and Tempo. What shipped from that acquisition is a single, statically-linked Go binary with no separate server and no separate database to install — in that specific respect it shares a design philosophy with Prometheus: one binary, one config, nothing else to stand up before you can run a test.
"Developer-centric" is the word k6's own documentation uses for itself, and it's worth taking literally rather than as marketing: a k6 test script is a plain .js file that lives in the application's own repository, gets reviewed in the same pull requests as the code it tests, runs locally with the exact same k6 run command a CI job runs, and is versioned the same way. Contrast that with a GUI-driven recorder that produces an XML project file nobody reviews line by line, or a proprietary SaaS tool whose test definitions live outside the codebase entirely — k6's whole model is a bet that a load test is a kind of test, and should be treated like one.
JavaScript, but specifically not Node.js
This is the single most common point of confusion for anyone arriving from a Node.js background. k6's JavaScript runtime is goja, a pure-Go implementation of ECMAScript (roughly an ES2015/ES6-level subset), embedded directly in the binary — there is no Node.js underneath it, no npm install, and no arbitrary require('fs') reaching out to the host filesystem or network from inside a virtual user's code. In exchange, k6 ships its own curated set of built-in modules that cover what a load test actually needs: k6/http, k6/ws for WebSockets, k6/grpc, k6/browser for real browser automation over the Chrome DevTools Protocol (folded into core as of k6 v0.52, after graduating from the separate xk6-browser extension), k6/metrics, k6/crypto, k6/encoding, k6/execution, and k6/data. To use a third-party library, the usual paths are importing an already-bundled build from jslib.k6.io (a CDN of common libraries pre-transpiled to run inside goja) or bundling your own down to a single ES5/ES6-safe file with webpack or Babel before importing it. For anything that genuinely needs native Go capability — talking to Kafka, running raw SQL against a database as part of setup, a proprietary binary protocol — the xk6 build tool compiles a custom k6 binary with that capability linked in as an extension; the browser module lived exactly that life as xk6-browser before it graduated into core.
Three properties define k6's whole design, and everything else on this page is detail underneath them: virtual users and iterations describe a load profile as code, not a GUI thread-count field; thresholds turn a load test's output into the boolean a pipeline already understands — pass or fail, exit code 0 or 99 — instead of a report a human has to read and judge; and real-time output makes k6 a metrics producer any Grafana stack can already ingest, rather than yet another dashboard to babysit separately from the ones a team already watches.
How it works — architecture and the execution model
☺ Like you're 10: The one binary reads your script once, then loops your test function over and over on a pool of pretend users, until the clock or the iteration count says stop.
Every k6 run passes through the same four stages, in order. First, the init context — every line of the script outside the exported functions, including every import and the options object — runs once, and runs again for each virtual user k6 spins up, since that's where a VU's own JS environment gets set up; this is also why file reads via open() must happen in init code, not inside the loop. Second, setup() runs exactly once, globally, before any load starts — the standard place to mint a shared auth token or seed test data, with whatever it returns handed to every VU. Third, the exported default function is what each virtual user actually loops on, once per iteration, for as long as the test's executor says it should keep going. Fourth, teardown() runs exactly once, globally, after every VU has finished — cleanup, not assertions; a failed check inside teardown doesn't fail the run.
Executors: shaping the load profile
An executor is the piece of an executor's configuration that decides how VUs or iterations are scheduled over time — it's the direct answer to "what does the load actually look like." k6 ships several, and picking the right one is most of getting a realistic result:
| Executor | Model | What it controls | Reach for it when |
|---|---|---|---|
shared-iterations | closed | a fixed total number of iterations, divided across a VU pool | a quick smoke test with a known total volume |
per-vu-iterations | closed | each VU runs exactly N iterations | an apples-to-apples comparison between two test runs |
constant-vus | closed | a fixed VU count held for a fixed duration | the simplest possible steady-load check |
ramping-vus | closed | VU count moves through configured stages (ramp up, hold, ramp down) | the default most scripts start with — the stages shorthand below uses this executor implicitly |
constant-arrival-rate | open | a fixed rate of new iterations started per time unit, independent of response time | measuring true throughput capacity without a closed-loop's self-throttling bias |
ramping-arrival-rate | open | the iteration-start rate itself moves through stages | modeling a realistic traffic ramp (a launch, a marketing spike) without that same bias |
Open vs. closed models, and coordinated omission
This distinction is worth understanding precisely, because getting it wrong produces a load test that looks clean and hides the exact failure it was built to catch. In a closed-model executor (constant-vus, ramping-vus, and the iteration-count executors), a virtual user cannot start its next iteration until the current one finishes — send request, wait for response, optionally sleep(), then loop. That sounds harmless, but it means if the system under test starts responding slowly, the VUs' own request rate drops right along with it: the load generator quietly throttles itself in lockstep with the very slowdown it's supposed to be measuring. This effect has a name in the load-testing literature — coordinated omission — and it systematically under-represents tail latency during exactly the moments a test is meant to expose.
An open-model executor breaks that coupling on purpose. constant-arrival-rate tells k6 to start a fixed number of iterations per time unit no matter how long earlier ones are taking, allocating additional VUs — up to a configured ceiling — to keep the start rate honest:
export const options = {
scenarios: {
steady_arrival_rate: {
executor: 'constant-arrival-rate',
rate: 200, // start 200 iterations...
timeUnit: '1s', // ...every second, no matter how long each one takes
duration: '5m',
preAllocatedVUs: 100, // VUs k6 pre-allocates to try to sustain that rate
maxVUs: 300, // a hard ceiling — hit it and k6 reports dropped_iterations
},
},
};The theory underneath why this matters — why a system's tail latency bends sharply well before it hits 100% utilization, and why that knee is exactly what an open-model test is built to find — is worked through in full in queueing theory for SRE, which names k6 directly as one of the tools built for finding that knee deliberately, in a controlled test, instead of during a live incident.
An arrival-rate executor needs enough preAllocatedVUs/maxVUs headroom to actually sustain the configured rate. Run out of allocatable VUs and k6 doesn't fail loudly — it quietly under-delivers the configured rate and increments the dropped_iterations metric instead. Always check that metric after a run using this executor; a clean-looking result with a nonzero dropped_iterations count didn't test what the rate setting claimed it tested.
The script you actually write
☺ Like you're 10: Three moving parts do almost all the work: options sets the shape and the pass/fail rules, default() is what each pretend user does on repeat, and check() notices things without ending the test by itself.
Here's a realistic script against this course's own example service — the checkout API also used in Prometheus and SLIs, SLOs & error budgets. Read the comments; they mark the decisions that matter later.
// checkout-load-test.js — ramps to 50 VUs, holds, ramps down, gated by thresholds
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';
const paymentLatency = new Trend('payment_latency', true); // true → report in ms
export const options = {
stages: [
{ duration: '2m', target: 50 }, // ramp up to 50 VUs
{ duration: '5m', target: 50 }, // hold at 50 VUs
{ duration: '2m', target: 0 }, // ramp back down
],
thresholds: {
http_req_duration: ['p(95)<400', 'p(99)<800'], // p95 under 400ms, p99 under 800ms
http_req_failed: ['rate<0.01'], // fewer than 1% of requests fail
checks: ['rate>0.99'], // 99%+ of check() assertions pass
payment_latency: ['p(95)<300'], // gate on the custom metric too
},
};
export function setup() {
// runs ONCE, before any VU starts — the place to mint a token every VU will reuse
const res = http.post('https://staging.acme.example/auth/token', { grant_type: 'client_credentials' });
return { token: res.json('access_token') };
}
export default function (data) {
// this is the code every VU loops on, once per iteration
const headers = { Authorization: `Bearer ${data.token}` };
const cart = http.get('https://staging.acme.example/api/checkout/cart', { headers });
check(cart, { 'cart status is 200': (r) => r.status === 200 });
const start = Date.now();
const pay = http.post(
'https://staging.acme.example/api/checkout/pay',
JSON.stringify({ amount: 4200 }),
{ headers: { ...headers, 'Content-Type': 'application/json' } }
);
paymentLatency.add(Date.now() - start);
check(pay, { 'payment succeeded': (r) => r.status === 200 && r.json('success') === true });
sleep(1); // think time — omit this and every VU hammers the server back-to-back, see Gotchas
}
export function teardown(data) {
// runs ONCE, after every VU has finished — cleanup, not assertions
http.del('https://staging.acme.example/auth/token', null, {
headers: { Authorization: `Bearer ${data.token}` },
});
}check() and a threshold look similar but do fundamentally different jobs. A check() records a pass/fail count against whatever condition you give it — 'payment succeeded' above — purely as data; on its own it never ends the test or changes the exit code, no matter how many checks fail. A threshold is what turns data into a verdict: checks: ['rate>0.99'] is the line that actually fails the run if that check's pass rate drops below 99%. Write checks freely for visibility; the thresholds list is the only part of the script that can fail a build.
Thresholds: pass/fail criteria that gate a pipeline
☺ Like you're 10: This is the one paragraph in the whole script where a computer, not a person, gets to decide whether the answer is PASS or FAIL.
A threshold is a string expression — or an array of them — attached to a metric name in the options.thresholds object: p(95)<400, rate<0.01, avg<250, max<2000, and similar comparisons against count, min, and med are all valid, and they apply to any metric — a built-in one like http_req_duration or a custom Trend/Rate/Counter/Gauge the script itself defines, exactly like payment_latency above. Thresholds can also target a tagged sub-metric rather than the whole test's aggregate, using a brace suffix on the metric name:
thresholds: {
'http_req_duration{name:payment}': ['p(95)<300'], // only requests tagged name:payment
}A threshold entry can also be written as an object instead of a bare string, adding abortOnFail to stop the entire run early the moment that specific condition is confirmed breached, and delayAbortEval to give it a grace window so it isn't judged on too few early samples before a percentile is even meaningful:
thresholds: {
http_req_failed: [{ threshold: 'rate<0.01', abortOnFail: true, delayAbortEval: '10s' }],
}What makes this genuinely useful for CI, rather than just a nicer test report, is the exit code. k6 run exits 0 when the script completed without error and every threshold passed, and exits 99 specifically when one or more thresholds failed — a distinct code from other k6 error conditions like a script that fails to parse. A CI system only has to check whether the step exited non-zero, the exact mechanism that already fails a build on a failing unit test — no custom assertion logic has to be written into the pipeline at all:
# .github/workflows/load-test.yml
name: load-test
on: [pull_request]
jobs:
k6:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run k6 against staging
uses: grafana/k6-action@v0.3.1
with:
filename: checkout-load-test.js
# k6 exits 99 on a threshold breach — this step fails, the job fails,
# the pull request gets a red check. No extra assertion logic needed here.Because check() results only affect the exit code through a threshold built on the checks metric, a script with plenty of checks but no thresholds at all still exits 0 even if every single check failed — the run "completed," and completing is all an unthresholded script is ever judged on. It's a common way for a team to ship a load test that looks rigorous and gates nothing. If a script has checks, give it a checks: ['rate>0.99'] (or similarly strict) threshold, or the checks are decoration.
Day-to-day commands
☺ Like you're 10: One command runs it, a few flags change how, and two commands make it portable or turn a recording into a starting script.
# run locally, straight from the file
$ k6 run checkout-load-test.js
# override options from the CLI without touching the script
$ k6 run --vus 50 --duration 5m checkout-load-test.js
# pass values into the script via __ENV — e.g. `const host = __ENV.HOST;`
$ k6 run -e HOST=staging.acme.example checkout-load-test.js
# stream results to a file as the test runs, not just the end-of-run summary
$ k6 run --out json=results.json checkout-load-test.js
$ k6 run --out csv=results.csv checkout-load-test.js
# stream in real time to a Prometheus-remote-write-compatible endpoint (self-hosted
# Prometheus, Mimir, Cortex) — the exact flag name has shifted across k6 releases
# (experimental-prometheus-rw in older ones), so check `k6 run --help` on your version
$ k6 run --out experimental-prometheus-rw checkout-load-test.js
# bundle the script and every local file/module it imports into one portable unit
$ k6 archive checkout-load-test.js -O checkout.tar
$ k6 run checkout.tar # runs identically anywhere, no source needed
# see the fully-resolved options (scenarios, thresholds) without running anything
$ k6 inspect checkout-load-test.js
# convert an existing Postman collection into a starting-point k6 script
$ k6 convert postman_collection.json -O checkout-load-test.js
# run distributed, from Grafana's own managed load-generation infrastructure
$ k6 cloud login # one-time, needs a Grafana Cloud API token
$ k6 cloud run checkout-load-test.jsReporting into the Grafana ecosystem
☺ Like you're 10: Besides the printed report at the end, k6 can also whisper its numbers, second by second, to the exact same Grafana screen already showing whether the service is happy.
Every k6 run prints an end-of-run text summary by default — per-metric percentiles, and each threshold shown with a check or a cross next to it — which is genuinely useful for a local run but silent and easy to miss once tests run unattended in CI. The more durable path is real-time output: the --out flag streams every sample as it's collected, not just the final aggregate, to any of several built-in destinations — JSON and CSV files, InfluxDB, a Datadog agent, and a Prometheus-remote-write-compatible endpoint being the ones most teams reach for. (An older StatsD output existed too; it was deprecated and later removed from core, so treat any tutorial mentioning --out statsd as version-dependent.)
Pointed at a Prometheus-remote-write target, Grafana Labs publishes an official companion dashboard — "k6 Prometheus" on Grafana.com — built specifically to visualize that stream: virtual users, request rate, latency percentiles, and live threshold status, updating second by second in the same Grafana instance already showing the service's own production dashboards. That's the payoff of the whole reporting path: cause (the load k6 is generating) and effect (what the service's own metrics do in response) sit on one screen instead of two.
Grafana Cloud k6 — the direct SaaS descendant of Load Impact's original cloud product — runs the identical script from distributed load-generation points across multiple geographic regions instead of a single laptop or CI runner, with zero script changes: k6 cloud run in place of k6 run, once authenticated. It keeps the same threshold-driven pass/fail model, adds hosted dashboards and run-over-run comparisons, and trades your own compute for a bill. For teams that want distributed load from their own infrastructure instead, the grafana/k6-operator project defines a Kubernetes K6 custom resource that splits a script's configured VUs across multiple runner pods.
See Grafana for the dashboard layer generally, Prometheus and InfluxDB for the two most common storage backends k6 streams into, and the multi-window, multi-burn-rate alerting page for the PromQL a service's own SLO dashboard is built from — the thing a k6 run's traffic is stress-testing in the first place.
Gotchas and failure modes
☺ Like you're 10: Most k6 surprises come from one of two habits: expecting it to behave like Node.js, or forgetting that a "virtual user" isn't the same thing as a real one.
It's goja, not Node — no npm install, no raw require('fs')
The single most common source of confusion for newcomers is treating a VU script like ordinary Node.js code — reaching for require() on a Node built-in, or expecting npm install to make a package importable. Neither works inside goja's sandbox. The fix is one of the three paths covered above: pull a pre-bundled library from jslib.k6.io, bundle your own dependency down to a single file with webpack or Babel first, or, if the need is for genuine native capability rather than a JS library, build a custom binary with xk6.
A VU is not a user
"Fifty VUs" is not literally fifty concurrent humans clicking a checkout button — it's fifty loops of default() running back-to-back, and how closely that resembles real traffic depends entirely on sleep(). Omit it, or set it too low, and every VU hammers the server with zero think time between requests, inflating the effective request rate per VU far past anything a real user produces — useful on purpose for a deliberate stress test, misleading if the script's stated goal is "what does 50 concurrent users look like."
Thresholds evaluate against the whole run by default
A threshold like http_req_duration: ['p(95)<400'] is computed over every sample collected across the entire test unless it's scoped to a tag. A two-minute latency spike during ramp-up can get diluted by five quiet minutes afterward and never breach that aggregate threshold at all, even though real requests suffered real pain during that window. Scope thresholds to a tag ('http_req_duration{name:payment}', as shown above) or a scenario, and look at the time-series output — not only the end-of-run summary — to catch a localized breach the aggregate number hides.
One machine is a ceiling too
A single k6 process is bound by the CPU, open-socket, and network-egress limits of the machine running it — a laptop or a small CI runner tops out well below the load many production services actually need to be honestly stress-tested against. k6 has no built-in clustering of its own process; the paths to genuinely distributed load are Grafana Cloud k6, the k6 Operator on your own Kubernetes cluster, or manually running several k6 processes in parallel and combining their results, not a flag on the base binary.
http_req_failed only sees HTTP-level failure
The built-in http_req_failed metric is driven by the response's own status code and transport-level errors — a 500 counts. It has no idea that a service returned 200 OK with {"success": false} in the body, which is exactly how a lot of real application-level failure shows up. Without a check() on the response body wired into its own threshold — as payment succeeded does above — a load test can report a clean pass while every single payment silently failed.
Alternatives and when to choose it
☺ Like you're 10: Other tools throw the same pretend crowd at a system — they just write the crowd's instructions in a different language, or hand you a different kind of report at the end.
| Option | Model | Best when | Costs you |
|---|---|---|---|
| k6 | JavaScript scripts, single Go binary, thresholds as a native CI gate | Tests should live and be reviewed in the app's own repo; CI needs a load test to pass/fail like any other check | goja's JS subset, not full Node; no built-in distributed execution without Cloud or the Operator |
| Locust | Python, user behavior defined as code, distributed by design | The team already thinks in Python; cluster-scale distributed load out of the box matters most | Python's GIL shapes per-worker throughput; a web UI to run, not a single static binary |
| Apache JMeter | GUI-built or XML test plans, Java-based, huge protocol breadth | Protocols beyond HTTP matter — JDBC, JMS, FTP, and more; an established plugin ecosystem is valuable | XML test plans reviewed and diffed far less naturally than code; heavier resource footprint per load generator |
| Gatling | Scala/Java DSL, high throughput per load-generator node | Maximum throughput per test node matters, and the team is comfortable in the JVM ecosystem | A Scala DSL is a steeper on-ramp than JavaScript for most application teams |
| Artillery / Vegeta | Lightweight, config- or flag-driven HTTP(S) load generators | A quick one-off throughput check, no elaborate scripted user journey needed | Thin on the scripted, multi-step scenario and native CI-threshold model k6 is built around |
The practical rule most teams land on: reach for k6 by default when the people writing the test are the same people writing the service — the JavaScript, the repo-native workflow, and the exit-code-driven threshold model exist specifically to make a load test as natural to write and gate as a unit test. Reach for JMeter when a protocol outside plain HTTP is in scope, for Locust when Python and distributed-by-default execution matter more than the language match, and for Gatling when raw per-node throughput is the deciding factor. See the SRE toolchain for how load testing sits next to the rest of this course's tool categories, and capacity planning & performance for the discipline this whole category of tool exists to serve.
Sol the Sloth: Ran the checkout script at fifty VUs for five minutes. p95 held under four hundred milliseconds the whole time — clean pass.
Foxy: Did any of those fifty VUs ever have to wait on a slow response before starting their next one?
Sol: ...yes. Ramping-vus, closed loop. If the server had actually started struggling, my own VUs would've quietly slowed down right along with it.
Timmy the Turtle: So the test was protecting itself from measuring the exact thing you built it to measure.
Sol: Correct. Reran it with constant-arrival-rate — two hundred requests started per second flat, no matter how slow anything answered. That's when p99 actually cracked eleven hundred milliseconds.
Benny the Beaver: And the threshold caught it that time?
Sol: Exit code 99. Build failed on the pull request. Nobody found out about it from a customer instead.
Going further
☺ Like you're 10: This page is enough to write and gate a real test — the official docs are where the executor and output reference tables live in full.
The canonical source is the documentation at grafana.com/docs/k6 — the Scenarios and Thresholds pages are worth reading end to end once this page's shape is familiar — alongside the source at github.com/grafana/k6. k6 doesn't sit behind a dedicated vendor certification the way some tools on this course's toolchain do; treat fluency with it as demonstrated by a working, thresholded script rather than a badge. Pair this page with capacity planning & performance for why load testing exists in the first place, queueing theory for SRE for the math behind the knee a well-designed k6 test is trying to find, and SRE Tools & Automation for where load/performance testing sits among the SREF blueprint's toolchain categories — it's one the exam expects you to recognize by function, not by product name. If you want hands-on reps writing and gating a script rather than just reading about one, Capstone Part 5 — capacity plan & load test is exactly that exercise.
1. What makes k6 "developer-centric" compared with a GUI-recorded tool, and what does that concretely buy a team? 2. Distinguish a check() from a threshold — which one can fail a k6 run's exit code, and which can't on its own? 3. What's the difference between a closed-model executor like ramping-vus and an open-model one like constant-arrival-rate, and why does that difference matter for coordinated omission? 4. What exit code does k6 return when a threshold fails, and why does that matter for wiring a load test into CI without extra scripting? 5. Name two ways k6's results can reach a Grafana dashboard while a test is still running, not only after it finishes. 6. Why can a load test that only checks http_req_failed still report a false pass on a service whose responses are silently wrong?
Check your answers
- Test scripts are plain JavaScript files that live in the app's own repository, get reviewed in the same pull requests as the code they test, and run with the same command locally and in CI — versus an XML project from a GUI recorder or a test definition living outside the codebase. It buys reviewability, versioning, and a natural place in the same pipeline as unit tests.
check()only records a pass/fail count as data and never changes the exit code by itself, no matter how many checks fail. A threshold is what turns a metric (including thechecksmetric itself) into a verdict — only a threshold can fail the run.- A closed-model executor won't start a VU's next iteration until the current one finishes, so if the system slows down, the VUs' own request rate quietly slows down with it — under-representing tail latency during exactly the moment it matters (coordinated omission). An open-model executor like
constant-arrival-ratestarts iterations at a fixed rate regardless of how long earlier ones take, decoupling load from response time. - Exit code 99 specifically means one or more thresholds failed. Because CI systems already fail a build on any non-zero exit code, this lets a load test gate a pipeline with zero custom assertion logic — the same mechanism that fails a build on a failing unit test.
- Any two of: the default end-of-run text summary printed to the terminal (only useful while watching live); real-time streaming via
--outto a Prometheus-remote-write-compatible endpoint feeding Grafana Labs' official "k6 Prometheus" dashboard; or Grafana Cloud k6's own hosted, live dashboards when running viak6 cloud run. - The built-in
http_req_failedmetric only reflects HTTP-level failure — a bad status code or transport error. A response that returns200 OKwith an application-level failure encoded in the body (e.g.{"success": false}) never trips it; only acheck()on the response body, wired into its own threshold, would catch that.