K6
Script lifecycle — the thing to internalise first
// 1. init — runs once per VU, on every VU. No HTTP allowed here.
import http from 'k6/http';
const payload = JSON.parse(open('./data.json'));
export const options = { /* 2. config, read once from the first init */ };
export function setup() {
// 3. runs ONCE for the whole test. Return value must be JSON-serialisable.
const res = http.post(`${BASE}/api/auth/token/`, creds);
return { token: res.json('access') };
}
export default function (data) {
// 4. the VU loop. Runs over and over, per VU, for the test duration.
http.get(`${BASE}/api/patients/`, { headers: { Authorization: `Bearer ${data.token}` } });
}
export function teardown(data) { /* 5. once, at the end */ }Init code runs once per VU, not once per test. 1000 VUs × a 50MB open() = 50GB. That’s the single most common way people OOM their load generator. Use SharedArray:
import { SharedArray } from 'k6/data';
const patients = new SharedArray('patients', () => JSON.parse(open('./patients.json')));Each VU is a separate JS runtime with its own globals. No shared mutable state between VUs — coordinate via setup()’s return value or an external service.
Executors
The most important configuration decision in k6.
| Executor | Controls | Use for |
|---|---|---|
shared-iterations |
N iterations split across VUs | Fixed work, e.g. process 10k records |
per-vu-iterations |
N iterations each | Deterministic per-user journeys |
constant-vus |
Fixed concurrency | Simulating N connected clients |
ramping-vus |
VU count over stages | Legacy; mostly the wrong choice |
constant-arrival-rate |
Fixed req/s | Load tests against an SLO |
ramping-arrival-rate |
req/s over stages | Stress tests, finding the knee |
externally-controlled |
Live via REST API | Manual exploration |
Use arrival-rate executors by default. VU-based executors reduce offered load when your server slows down, which hides the failure mode you’re testing for (coordinated omission). Arrival-rate keeps the pressure on and reports dropped_iterations when it can’t keep up — that metric is your signal that maxVUs was too low.
export const options = {
scenarios: {
api: {
executor: 'ramping-arrival-rate',
startRate: 10, timeUnit: '1s',
preAllocatedVUs: 100, maxVUs: 2000,
stages: [{ target: 500, duration: '10m' }],
},
},
};Scenarios can run in parallel, use different exec functions, and be offset with startTime.
Metrics
Four types: Counter, Gauge, Rate, Trend. Everything built-in is one of these.
Key built-ins: - http_req_duration = sending + waiting + receiving. Excludes DNS/TCP/TLS. - http_req_waiting — TTFB. Usually the number that reflects server work. - http_req_blocked — includes connection-pool wait and DNS. High values here often mean your generator is the bottleneck. - iteration_duration, dropped_iterations, vus, vus_max, data_sent/data_received, checks
Custom metrics:
import { Trend, Counter } from 'k6/metrics';
const dbHeavyLatency = new Trend('db_heavy_latency', true);
const authFailures = new Counter('auth_failures');Checks vs thresholds
Checks are assertions that record a pass rate but never fail the test. Thresholds are the pass/fail criteria — they set the exit code, which is what CI cares about.
thresholds: {
'http_req_failed': ['rate<0.01'],
'http_req_duration{endpoint:submit}': ['p(95)<500', 'p(99)<1200'],
'checks{type:critical}': ['rate>0.99'],
'http_req_duration': [{ threshold: 'p(99)<2000', abortOnFail: true, delayAbortEval: '1m' }],
}abortOnFail stops the run early once the system is clearly broken — saves you 20 minutes of hammering a dead service.
Tagging — and the URL cardinality trap
Dynamic URLs create one metric series per URL and blow up your output. Always name them:
http.get(`${BASE}/api/patients/${id}/`, {
tags: { name: '/api/patients/:id', endpoint: 'patient_detail' },
});group() adds a group tag and nests results. Note that group does not create a transaction boundary — it’s labelling, not timing scope, unless you use the group duration metric.
HTTP specifics
// Parallel requests within one iteration
const responses = http.batch([
['GET', `${BASE}/api/patients/`, null, params],
['GET', `${BASE}/api/questionnaires/`, null, params],
]);
// Async, when you need Promise semantics
const res = await http.asyncRequest('GET', url, null, params);Connection reuse, cookie jars, and redirect following are on by default. http.get(url, { responseType: 'none' }) or the global discardResponseBodies: true cuts memory dramatically when you don’t inspect bodies.
Beyond HTTP
k6/browser— Playwright-like async API driving real Chromium. Use it sparingly: browser VUs cost ~100× a protocol VU. The correct pattern is a mostly-protocol test with one small browser scenario to capture Core Web Vitals. Recent versions can also attach to an existing Chromium over CDP, mirroring Playwright’sbrowserType.connect.k6/net/grpc— unary and streaming, with.load()for protos.k6/experimental/websockets— the standard-ish WebSocket API.- xk6 extensions — SQL, Kafka, MQTT, Redis, AMQP, and
xk6-disruptorfor fault injection. Built by compiling a custom binary:xk6 build --with github.com/grafana/xk6-sql.
Outputs and distributed runs
k6 run script.js --out experimental-prometheus-rw # → Prometheus/Grafana
k6 run script.js --out json=results.json
k6 run script.js --out csv=results.csvSet K6_PROMETHEUS_RW_TREND_STATS=p(95),p(99),max or you get useless trend aggregation.
For load beyond one machine: k6-operator on Kubernetes (a TestRun CRD that shards a script across N pods), or Grafana Cloud k6. Note that percentiles do not aggregate correctly across shards unless the backend supports it — sum counters, don’t average p95s.
TypeScript and modules
Modern k6 runs ES2015+ natively via Sobek and strips TypeScript types directly — no Babel, no build step for plain TS. You only need a bundler (esbuild/webpack) if you import npm packages, since there’s no Node API surface: no fs, no crypto module, no Buffer, no process. Use k6/crypto, k6/encoding, open(), and __ENV.
import { Faker } from 'k6/x/faker' style imports only work with a custom binary.
CLI worth knowing
k6 new # scaffold a script
k6 run --vus 10 --duration 30s script.js
k6 run --http-debug=full # dump requests/responses
k6 inspect script.js # resolved options, no execution
k6 archive script.js # bundle script + data into .tar for CI/cloud
k6 cloud run --local-execution # run locally, stream results to Grafana CloudAlso k6 Studio — a desktop app that records a browser session and generates a script. Useful as a starting skeleton, not as a finished test.
Pitfalls
- Your generator is the bottleneck. Raise
ulimit -n, widen the ephemeral port range, and watch k6’s own CPU. Above ~70% CPU your latency numbers include queueing in k6 itself. sleep()inside an arrival-rate executor still consumes a VU for its duration. That’s whatpreAllocatedVUs/maxVUsare sized for, and whydropped_iterationsclimbs if you under-allocate.- DNS is cached per VU by default. If you’re testing behind a load balancer with rotating IPs, you may be hitting one backend the whole run.
- Thresholds on
http_req_durationwithout tags average across all endpoints and tell you nothing. setup()timeouts — default is 60s. Seeding a large fixture insetup()will silently kill the run.- k6 doesn’t do think-time modelling for you. No pacing means an unrealistic burst pattern.
For your Django stack specifically
Bake the auth dance into setup() once and pass the token down — re-authing per iteration load-tests your JWT signing, not your app. Tag every request by view name so http_req_duration{endpoint:...} lines up with what you see in pg_stat_statements. And run the harness from a different Proxmox VM than the app; loopback has no TLS handshake, no MTU, and no realistic connection cost.