K6 Load Testing
A practical reference for k6 — Grafana's load testing tool. Scripts run on a Go-based VM that exposes a JavaScript runtime, so most modern JS works (no DOM).
Basic Script Structure
// script.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 10, // 10 virtual users
duration: '30s', // for 30 seconds
};
export default function () {
const res = http.get('https://api.example.com/users');
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1); // 1 second think-time per VU per iteration
}Run it:
k6 run script.jsThe default function is the VU code — k6 runs it repeatedly per virtual user for the duration.
Init vs VU code
// — Init context (runs once per VU at startup) —
import http from 'k6/http';
const baseUrl = __ENV.BASE_URL ?? 'https://api.example.com';
// — VU context (runs per iteration) —
export default function () {
http.get(`${baseUrl}/health`);
}You can't make HTTP calls from init code — only file reads, imports, and constant setup belong there.
HTTP Requests
GET / POST / PUT / PATCH / DELETE
http.get('https://api.example.com/users');
http.post('https://api.example.com/users',
JSON.stringify({ name: 'Ada', email: 'ada@example.com' }),
{ headers: { 'Content-Type': 'application/json' } });
http.put('https://api.example.com/users/42', body, params);
http.patch('https://api.example.com/users/42', body, params);
http.del('https://api.example.com/users/42', null, params);Headers and auth
const params = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${__ENV.API_TOKEN}`,
'X-Request-ID': `${__VU}-${__ITER}`,
},
timeout: '15s',
tags: { name: 'create-user' },
};
http.post(url, JSON.stringify(payload), params);Reading the response
const res = http.get(url);
res.status; // 200
res.body; // raw text
res.json(); // parsed JSON
res.json('items.0.name'); // dot-path access
res.headers['Content-Type'];
res.timings.duration; // total time, ms
res.timings.waiting; // TTFB
res.timings.connecting;Form data and file uploads
const fileData = open('./fixtures/users.csv', 'b'); // 'b' = binary
http.post('https://api.example.com/import', {
file: http.file(fileData, 'users.csv', 'text/csv'),
description: 'Bulk import',
});Batch (parallel) requests
const responses = http.batch([
['GET', 'https://api.example.com/users'],
['GET', 'https://api.example.com/orders'],
['GET', 'https://api.example.com/products'],
]);
check(responses[0], { 'users 200': (r) => r.status === 200 });Checks (Assertions)
Checks return a boolean per condition — they record pass/fail in the metrics but do not stop the test.
import { check } from 'k6';
check(res, {
'status is 200': (r) => r.status === 200,
'body contains success': (r) => r.body.includes('"status":"ok"'),
'response time < 500ms': (r) => r.timings.duration < 500,
'response is JSON': (r) => r.headers['Content-Type'].includes('application/json'),
'JSON has id': (r) => r.json('id') !== undefined,
});Tagging checks per group
check(res, { 'login ok': (r) => r.status === 200 }, { feature: 'auth' });When you actually want to stop
import { fail } from 'k6';
const res = http.post('/login', payload);
if (res.status !== 200) {
fail(`login failed (${res.status})`);
}Thresholds
Thresholds are pass/fail criteria for the whole run — k6 exits non-zero if any threshold fails. This is what makes k6 usable in CI.
export const options = {
vus: 50,
duration: '5m',
thresholds: {
// p95 latency under 500ms, p99 under 1s
http_req_duration: ['p(95)<500', 'p(99)<1000'],
// less than 1% failed requests
http_req_failed: ['rate<0.01'],
// total requests above 10000
http_reqs: ['count>10000'],
// per-tag thresholds (using request tags)
'http_req_duration{name:create-user}': ['p(95)<800'],
// multiple criteria with custom abort
checks: [{
threshold: 'rate>0.95',
abortOnFail: true,
delayAbortEval: '30s', // grace period before evaluating
}],
},
};Abort on threshold breach
thresholds: {
http_req_failed: [{ threshold: 'rate<0.05', abortOnFail: true }],
}When the failure rate exceeds 5%, k6 stops the run immediately instead of continuing for the rest of the duration.
Load Patterns (Scenarios)
Scenarios are how you describe non-trivial load shapes. Multiple scenarios can run in the same script.
Constant VUs
export const options = {
scenarios: {
steady: {
executor: 'constant-vus',
vus: 50,
duration: '5m',
},
},
};Ramping VUs (the classic load curve)
export const options = {
scenarios: {
ramp: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 50 }, // ramp up
{ duration: '5m', target: 50 }, // hold
{ duration: '2m', target: 100 }, // step up
{ duration: '5m', target: 100 },
{ duration: '2m', target: 0 }, // ramp down
],
},
},
};Constant arrival rate (target req/sec independent of latency)
export const options = {
scenarios: {
target_100rps: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1s',
duration: '10m',
preAllocatedVUs: 50,
maxVUs: 200,
},
},
};ramping-vus ramps users; constant-arrival-rate ramps traffic. The latter is closer to what production sees.
Ramping arrival rate
ramp_traffic: {
executor: 'ramping-arrival-rate',
startRate: 10,
timeUnit: '1s',
preAllocatedVUs: 50,
maxVUs: 500,
stages: [
{ duration: '2m', target: 100 },
{ duration: '10m', target: 100 },
{ duration: '2m', target: 0 },
],
}Iteration-based (no time limit)
shared: {
executor: 'shared-iterations',
vus: 10,
iterations: 1000, // 1000 total, divided among 10 VUs
}
per_vu: {
executor: 'per-vu-iterations',
vus: 10,
iterations: 100, // 100 per VU = 1000 total, but each VU runs exactly 100
}Common load profiles
| Profile | Goal | Shape |
|---|---|---|
| Smoke | Verify the script and SUT respond at all | 1 VU, 1 minute |
| Load | Validate behaviour at expected peak | ramp up → hold for 10–30 min → ramp down |
| Stress | Find the breaking point | ramp past expected peak until errors / timeouts spike |
| Spike | Validate behaviour under a burst | jump from 10 to 500 VUs in seconds, hold 1 min |
| Soak | Find leaks and degradation over time | hold a moderate load for 4–24 h |
Groups & Tags
group — organise requests logically
import { group } from 'k6';
export default function () {
group('login flow', () => {
const res = http.post('/login', creds);
check(res, { 'logged in': (r) => r.status === 200 });
});
group('browse catalog', () => {
http.get('/products');
http.get('/products/42');
});
}Each group surfaces in the summary as its own metric, so you can read latency per business flow.
Tags — attach metadata to metrics
http.get('/orders', { tags: { name: 'list-orders', endpoint: 'orders' } });
http.get('/orders/42', { tags: { name: 'get-order', endpoint: 'orders' } });Then filter results by tag:
thresholds: {
'http_req_duration{endpoint:orders}': ['p(95)<400'],
}k6 run --tag environment=staging script.js # global tag from CLICustom Metrics
import { Counter, Gauge, Rate, Trend } from 'k6/metrics';
const errors = new Counter('app_errors');
const usersOnline = new Gauge('app_users_online');
const cacheHits = new Rate('app_cache_hit_rate');
const dbTime = new Trend('app_db_time');
export default function () {
const res = http.get('/dashboard');
errors.add(res.status >= 500 ? 1 : 0);
cacheHits.add(res.headers['X-Cache'] === 'HIT');
usersOnline.add(res.json('users_online'));
dbTime.add(res.timings.duration);
}Use them in thresholds:
thresholds: {
app_cache_hit_rate: ['rate>0.8'],
app_db_time: ['p(95)<200'],
}Data & Parameterization
SharedArray — memory-efficient test data
Without SharedArray, every VU gets its own copy of the data — fine for 100 rows, ruinous for 100k.
import { SharedArray } from 'k6/data';
const users = new SharedArray('users', () => {
return JSON.parse(open('./fixtures/users.json'));
});
export default function () {
const user = users[Math.floor(Math.random() * users.length)];
http.post('/login', JSON.stringify(user));
}CSV with papaparse
import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';
import { SharedArray } from 'k6/data';
const users = new SharedArray('users', () =>
papaparse.parse(open('./fixtures/users.csv'), { header: true }).data
);Built-in __VU and __ITER
export default function () {
const i = (__VU - 1) * 1000 + __ITER; // unique id per VU/iteration
http.post('/items', JSON.stringify({ ref: `run-${i}` }));
}Unique data per VU
const users = new SharedArray('users', () => /* ... */);
export default function () {
const user = users[(__VU - 1) % users.length]; // each VU gets its own row
http.post('/login', JSON.stringify(user));
}Environment & Configuration
Environment variables
const baseUrl = __ENV.BASE_URL ?? 'https://api.example.com';
const token = __ENV.API_TOKEN;k6 run --env BASE_URL=https://staging.example.com \
--env API_TOKEN=$TOKEN \
script.jsConfig from JSON
k6 run --config config.json script.jsconfig.json:
{
"vus": 100,
"duration": "10m",
"thresholds": {
"http_req_duration": ["p(95)<500"]
}
}CLI flags override config values; config values override export const options in the script.
Common CLI flags
k6 run --vus 50 --duration 5m script.js
k6 run --iterations 1000 script.js
k6 run --quiet script.js # less log noise
k6 run --no-thresholds script.js # skip threshold evaluationOutput and integrations
# JSON / CSV
k6 run --out json=results.json script.js
k6 run --out csv=results.csv script.js
# InfluxDB (real-time dashboards in Grafana)
k6 run --out influxdb=http://localhost:8086/k6 script.js
# Prometheus remote write
K6_PROMETHEUS_RW_SERVER_URL=http://prom:9090/api/v1/write \
k6 run --out experimental-prometheus-rw script.js
# Grafana Cloud k6
k6 cloud script.jsHTML summary report
import { htmlReport } from 'https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js';
export function handleSummary(data) {
return {
'summary.html': htmlReport(data),
'stdout': textSummary(data, { indent: ' ', enableColors: true }),
'summary.json': JSON.stringify(data),
};
}Lifecycle Hooks
import http from 'k6/http';
// 1. Init code — runs once per VU at startup
const baseUrl = __ENV.BASE_URL ?? 'https://api.example.com';
// 2. setup() — runs ONCE before any VU starts
export function setup() {
const loginRes = http.post(`${baseUrl}/auth/token`, JSON.stringify({
client_id: __ENV.CLIENT_ID, client_secret: __ENV.CLIENT_SECRET,
}), { headers: { 'Content-Type': 'application/json' } });
return { token: loginRes.json('access_token') };
}
// 3. default — runs per VU per iteration
export default function (data) {
http.get(`${baseUrl}/me`, {
headers: { 'Authorization': `Bearer ${data.token}` },
});
}
// 4. teardown() — runs ONCE after all VUs are done
export function teardown(data) {
http.post(`${baseUrl}/auth/revoke`, JSON.stringify({ token: data.token }));
}| Stage | Runs | Use for |
|---|---|---|
| Init | Once per VU at startup | Imports, constants, SharedArray setup |
setup() | Once total, before VUs | Auth tokens, seeding test data, cleanup of prior runs |
default() | Per VU per iteration | The actual load — HTTP calls, checks |
teardown() | Once total, after VUs | Revoke tokens, write summary, clean up DB rows |
The return value of setup() is JSON-serialised and passed into both default(data) and teardown(data) — keep it small.