Install k6, write a complete load test script with virtual users, stages, groups, checks, and SLO thresholds, then interpret the terminal summary output.
k6 is a developer-focused load testing tool written in Go with a JavaScript scripting API. Unlike JMeter XML configuration or Gatling Scala DSL, k6 tests are plain JavaScript files you write in your editor and commit to version control. k6 executes each virtual user in a Go goroutine, making it extremely resource-efficient — a laptop can drive 1,000 VUs while using only 512 MB of RAM.
This lesson walks through every part of a k6 script: the options export where you configure VUs, duration, and thresholds; lifecycle hooks for setup and teardown; the default function that each VU executes on each iteration; and the summary output k6 prints when the test completes.
k6 is available as a native binary, Docker image, and GitHub Actions action.
1# macOS
2brew install k6
3
4# Windows
5choco install k6
6
7# Linux (Debian/Ubuntu)
8sudo gpg --no-default-keyring \
9 --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
10 --keyserver hkp://keyserver.ubuntu.com:80 \
11 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
12echo 'deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main' \
13 | sudo tee /etc/apt/sources.list.d/k6.list
14sudo apt-get update && sudo apt-get install k6
15
16# Docker — no install required
17docker run --rm -i grafana/k6 run - < script.js
18
19k6 version # Verify: k6 v0.54.0 (go1.22.0, linux/amd64)Organise performance tests in a dedicated directory:
1project/
2├── src/
3├── tests/
4│ ├── unit/
5│ ├── e2e/
6│ └── perf/
7│ ├── scenarios/ # smoke.js, load.js, stress.js
8│ ├── helpers/ # shared auth, data helpers
9│ └── data/ # CSV test data files
10└── package.jsonAdd npm scripts for convenience:
1{
2 "scripts": {
3 "perf:smoke": "k6 run tests/perf/scenarios/smoke.js",
4 "perf:load": "k6 run tests/perf/scenarios/load.js",
5 "perf:stress": "k6 run tests/perf/scenarios/stress.js"
6 }
7}Every k6 script has three parts: the options export (configuration), lifecycle hooks (setup/teardown), and the default exported function (per-VU workload).
1import http from 'k6/http';
2import { check, sleep } from 'k6';
3
4// 1. OPTIONS — k6 reads this before starting the test
5export const options = {
6 vus: 10,
7 duration: '30s',
8 // OR stages mode for ramp up/down:
9 // stages: [
10 // { duration: '2m', target: 20 },
11 // { duration: '5m', target: 20 },
12 // { duration: '1m', target: 0 },
13 // ],
14 thresholds: {
15 http_req_duration: ['p(95)<500'],
16 http_req_failed: ['rate<0.01'],
17 },
18};
19
20// 2. SETUP — runs once before VUs start; return value is passed to default()
21export function setup() {
22 const res = http.post('https://api.example.com/auth/token', {
23 username: 'perf-test-user',
24 password: __ENV.PERF_PASSWORD,
25 });
26 return { token: res.json('access_token') };
27}
28
29// 3. DEFAULT FUNCTION — each VU calls this repeatedly during the test
30export default function (data) {
31 const headers = { Authorization: `Bearer ${data.token}` };
32 const res = http.get('https://api.example.com/dashboard', { headers });
33 check(res, {
34 'status 200': (r) => r.status === 200,
35 'has user': (r) => Boolean(r.json('user')),
36 });
37 sleep(1); // model think time between requests
38}
39
40// 4. TEARDOWN — runs once after all VUs finish
41export function teardown(data) {
42 http.del('https://api.example.com/auth/sessions', null, {
43 headers: { Authorization: `Bearer ${data.token}` },
44 });
45}group() organizes related requests into named scopes and surfaces per-group metrics. check() creates named assertions that contribute to the checks metric shown in the summary.
1import http from 'k6/http';
2import { check, group, sleep } from 'k6';
3
4export const options = { vus: 20, duration: '1m' };
5
6export default function () {
7 group('User authentication', () => {
8 const loginRes = http.post(
9 'https://api.example.com/auth/login',
10 JSON.stringify({ email: '[email protected]', password: 'Pass123!' }),
11 { headers: { 'Content-Type': 'application/json' } }
12 );
13
14 const loginOk = check(loginRes, {
15 'login status 200': (r) => r.status === 200,
16 'has access token': (r) => typeof r.json('accessToken') === 'string',
17 'token not empty': (r) => r.json('accessToken').length > 0,
18 });
19
20 if (!loginOk) return; // stop if login failed
21
22 const token = loginRes.json('accessToken');
23 const h = { Authorization: `Bearer ${token}` };
24
25 sleep(0.5);
26
27 group('Dashboard fetch', () => {
28 const dashRes = http.get('https://api.example.com/dashboard', { headers: h });
29 check(dashRes, {
30 'dashboard 200': (r) => r.status === 200,
31 'has activity': (r) => Array.isArray(r.json('recentActivity')),
32 'under 300ms': (r) => r.timings.duration < 300,
33 });
34 });
35 });
36
37 sleep(1);
38}
39// Summary shows:
40// group::User authentication.... ✓ 2840/2880 (98.6%)
41// group::Dashboard fetch......... ✓ 2790/2840 (98.2%)Without thresholds, k6 exits code 0 even if p99 is 10 seconds. With thresholds, k6 exits code 99 when an SLO is breached — enabling CI pipelines to block deployments automatically.
1export const options = {
2 vus: 50,
3 duration: '10m',
4 thresholds: {
5 // Multiple conditions on built-in metrics
6 http_req_duration: [
7 'p(95)<500',
8 'p(99)<1500',
9 'avg<300',
10 'max<5000',
11 ],
12 http_req_failed: ['rate<0.001'],
13 http_reqs: ['count>1000'], // verify load was actually sent
14
15 // Per-endpoint thresholds via tags
16 'http_req_duration{name:login}': ['p(95)<300'],
17 'http_req_duration{name:search}': ['p(95)<800'],
18
19 // Abort early if errors spike — system is failing
20 http_req_failed: [{
21 threshold: 'rate<0.05',
22 abortOnFail: true,
23 delayAbortEval: '30s', // wait 30s to confirm it is not transient
24 }],
25 },
26};In CI (GitHub Actions):
1- name: Run k6 load test
2 run: k6 run tests/perf/load.js --out json=results.json
3# k6 exits code 99 if any threshold fails
4# GitHub Actions marks the step failed automatically
5# Add: if: always() on result upload to capture failure artifactsk6 prints a structured summary when the test ends. The key sections to read:
1 scenarios: (100.00%) 1 scenario, 50 max VUs, 10m30s max duration
2 * default: 50 looping VUs for 10m0s
3
4✓ status 200 ← check pass rate
5✓ has user field
6
7 checks.........................: 99.85% ✓ 14977 ✗ 22
8 data_received..................: 45 MB 75 kB/s
9 data_sent......................: 3.2 MB 5.3 kB/s
10 http_req_blocked...............: avg=1.21ms min=0s max=312ms
11 http_req_connecting............: avg=0.87ms min=0s max=311ms
12 ✓ http_req_duration..............: avg=187ms min=12ms max=2.1s p(90)=380ms p(95)=450ms
13 ✗ http_req_failed................: 0.14% ✗ threshold rate<0.001 breached
14 http_reqs......................: 15000 25/s
15 iteration_duration.............: avg=1.19s min=1.01s max=3.12s
16 vus............................: 50 min=50 max=50
17
18INFO thresholds on metrics 'http_req_failed' have been crossed
19FAIL (exit code 99)Read in this order:
http_req_duration p(95) — within threshold?http_req_failed — error rate within budget?checks — what percentage passed?http_reqs rate (per second) — was load actually applied?iteration_duration vs http_req_duration — gap indicates sleep time is correctA complete k6 script testing a Next.js product search endpoint with authentication, parameterized requests, groups, checks, and CI-ready thresholds.
1// tests/perf/scenarios/search-load.js
2// Run: k6 run --env API_BASE=http://localhost:3000 tests/perf/scenarios/search-load.js
3
4import http from 'k6/http';
5import { check, group, sleep } from 'k6';
6import { SharedArray } from 'k6/data';
7import { Rate } from 'k6/metrics';
8
9// SharedArray: parsed once, shared across all VUs (memory efficient)
10const searchTerms = new SharedArray('search terms', function () {
11 return [
12 'react hooks', 'typescript generics', 'node.js streams',
13 'postgresql indexes', 'docker compose', 'jest mocking',
14 'tailwind css', 'next.js app router', 'prisma relations',
15 ];
16});
17
18const searchErrors = new Rate('search_errors');
19
20export const options = {
21 scenarios: {
22 warmup: {
23 executor: 'constant-vus',
24 vus: 1,
25 duration: '30s',
26 exec: 'warmupExec',
27 tags: { scenario: 'warmup' },
28 },
29 search_load: {
30 executor: 'ramping-vus',
31 startTime: '35s',
32 startVUs: 0,
33 stages: [
34 { duration: '3m', target: 25 },
35 { duration: '8m', target: 50 },
36 { duration: '5m', target: 50 },
37 { duration: '2m', target: 0 },
38 ],
39 exec: 'searchTest',
40 tags: { scenario: 'load' },
41 },
42 },
43 thresholds: {
44 'http_req_duration{scenario:load}': ['p(95)<800', 'p(99)<2000'],
45 'http_req_failed{scenario:load}': ['rate<0.005'],
46 search_errors: ['rate<0.01'],
47 'http_reqs{scenario:load}': ['count>5000'],
48 },
49};
50
51const API_BASE = __ENV.API_BASE || 'http://localhost:3000';
52
53export function warmupExec() {
54 http.get(`${API_BASE}/api/health`);
55 sleep(1);
56}
57
58export function searchTest() {
59 const term = searchTerms[Math.floor(Math.random() * searchTerms.length)];
60 const params = new URLSearchParams({ q: term, limit: '10', page: '1' });
61
62 group('Search request', () => {
63 const res = http.get(
64 `${API_BASE}/api/search?${params}`,
65 { headers: { Accept: 'application/json' }, tags: { name: 'search-api' } }
66 );
67
68 const ok = check(res, {
69 'status 200': (r) => r.status === 200,
70 'json content-type': (r) =>
71 r.headers['Content-Type'].includes('application/json'),
72 'results array': (r) => Array.isArray(r.json('results')),
73 'meta.total number': (r) => typeof r.json('meta.total') === 'number',
74 'under 1s': (r) => r.timings.duration < 1000,
75 });
76
77 searchErrors.add(!ok);
78
79 if (ok && res.json('results').length > 0) {
80 group('Product detail', () => {
81 const id = res.json('results.0.id');
82 const detailRes = http.get(
83 `${API_BASE}/api/products/${id}`,
84 { tags: { name: 'product-detail' } }
85 );
86 check(detailRes, {
87 'detail 200': (r) => r.status === 200,
88 'has price': (r) => typeof r.json('price') === 'number',
89 });
90 });
91 }
92 });
93
94 sleep(0.5 + Math.random() * 2); // 0.5-2.5s think time
95}
96
97export function handleSummary(data) {
98 return {
99 'tests/perf/results/summary.json': JSON.stringify(data, null, 2),
100 };
101}This script demonstrates production k6 patterns: SharedArray for memory-efficient test data shared across all VUs, two scenarios (warmup + load) with staggered start times to prevent cold-start latency contaminating load results, per-scenario threshold filtering using tags, random sleep for realistic think time, and a handleSummary hook writing a JSON results file for CI artifact upload. Run: k6 run --env API_BASE=http://localhost:3000 tests/perf/scenarios/search-load.js
The next lesson covers k6 executor types — constant-vus, ramping-vus, constant-arrival-rate, and ramping-arrival-rate — and how to compose multi-scenario scripts for smoke, load, stress, and spike testing.
Your progress is saved in this browser, on this device only.