Install
# Debian / Ubuntu
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
--keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
| sudo tee /etc/apt/sources.list.d/k6.list
sudo apt update && sudo apt install k6
# macOS
brew install k6
# Or via mise / docker
mise use -g k6@latest
docker run --rm -i grafana/k6 run - < test.js
k6 version
The simplest test
// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 10, // virtual users
duration: '30s',
};
export default function () {
const res = http.get('https://api.example.com/users');
check(res, {
'is 200': (r) => r.status === 200,
'fast enough': (r) => r.timings.duration < 200,
});
sleep(1);
}
k6 run load-test.js
10 virtual users, each hitting the URL once per second for 30 seconds. After completion, k6 prints a summary:
✓ is 200
✓ fast enough
checks.........................: 100.00% ✓ 300 ✗ 0
http_req_duration..............: avg=87ms min=42ms med=85ms max=298ms
{ expected_response:true }...: avg=87ms min=42ms med=85ms max=298ms
http_req_failed................: 0.00% ✓ 0 ✗ 300
http_reqs......................: 300 9.9/s
iteration_duration.............: avg=1.08s min=1.04s med=1.08s max=1.30s
iterations.....................: 300 9.9/s
vus............................: 10
vus_max........................: 10
Thresholds: turn a test into a pass/fail
export const options = {
vus: 50,
duration: '5m',
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95th < 500ms, 99th < 1s
http_req_failed: ['rate<0.01'], // <1% failures
checks: ['rate>0.99'], // >99% checks pass
},
};
If any threshold is breached, k6 exits with non-zero status. Wire into CI:
# GitHub Actions
- run: k6 run load-test.js
Pull request fails if the load test regresses. Performance gate without standing up a separate tool.
Scenarios: ramp + steady + spike
export const options = {
scenarios: {
ramp_up: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 100 }, // ramp to 100 VUs
{ duration: '5m', target: 100 }, // hold
{ duration: '2m', target: 0 }, // ramp down
],
gracefulRampDown: '30s',
},
spike: {
executor: 'ramping-arrival-rate',
startRate: 0,
timeUnit: '1s',
preAllocatedVUs: 200,
stages: [
{ duration: '1m', target: 500 }, // 500 req/s
{ duration: '2m', target: 500 }, // hold
],
startTime: '11m', // run after ramp_up finishes
},
},
};
Mix scenarios in a single test: warm-up users, then a sudden spike, then a long hold. Realistic compound load patterns.
Executors
constant-vus— fixed VU count for a duration (default)ramping-vus— VU count changes over timeshared-iterations/per-vu-iterations— total iteration countsconstant-arrival-rate— X requests/sec regardless of duration of each (use this for closed-loop limit testing)ramping-arrival-rate— same, but with stagesexternally-controlled— change VUs at runtime via API
For "what's the steady-state throughput at fixed RPS?" use constant-arrival-rate. For "how does latency degrade as we add users?" use ramping-vus.
Realistic test patterns
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
// Load test data once, share across VUs (memory-efficient)
const users = new SharedArray('users', function () {
return JSON.parse(open('./test-users.json'));
});
export default function () {
const user = users[Math.floor(Math.random() * users.length)];
group('Auth flow', () => {
const login = http.post('https://api.example.com/login', {
email: user.email,
password: user.password,
});
check(login, { 'login ok': (r) => r.status === 200 });
const token = login.json('token');
const params = { headers: { 'Authorization': `Bearer ${token}` } };
group('Browse', () => {
http.get('https://api.example.com/products', params);
sleep(2);
http.get('https://api.example.com/orders', params);
});
group('Purchase', () => {
http.post('https://api.example.com/cart', { product_id: 42 }, params);
sleep(1);
http.post('https://api.example.com/checkout', null, params);
});
});
sleep(Math.random() * 3 + 1); // 1-4 second think time
}
Output to Prometheus / InfluxDB
# Stream metrics in real time to Prometheus remote-write
k6 run -o experimental-prometheus-rw \
-e K6_PROMETHEUS_RW_SERVER_URL=https://prometheus.example.com/api/v1/write \
load-test.js
# Or InfluxDB
k6 run -o influxdb=http://influx.lab:8086/k6 load-test.js
# Or just CSV
k6 run -o csv=results.csv load-test.js
Pair with Grafana dashboards (the k6 community has pre-built ones at grafana.com/dashboards) for live load-test observability.
Browser testing (k6 browser)
For end-to-end with actual browser sessions (Playwright-style), k6 has a browser module that runs Chromium:
import { browser } from 'k6/browser';
export const options = {
scenarios: {
ui: {
executor: 'shared-iterations',
options: { browser: { type: 'chromium' } },
},
},
};
export default async function () {
const page = await browser.newPage();
await page.goto('https://app.example.com');
await page.locator('#email').type('amir@example.com');
await page.locator('button[type=submit]').click();
await page.waitForSelector('h1');
// check, screenshot, assert UI
}
Heavy compared to HTTP-only tests; reserve for "end-to-end critical paths under load."
Distributed runs
For load that exceeds one machine's capacity, k6's k6 cloud (paid SaaS) handles distribution. For self-hosted, run k6 in Kubernetes with the k6-operator: define TestRun CRDs, the operator spawns N pods that run the test in parallel.
What k6 isn't
- Not a synthetic monitoring tool — for "ping our prod endpoints every minute," use uptime-kuma (see that tutorial) or a dedicated synthetic-monitoring service.
- Not a chaos-engineering tool — for fault injection, use Chaos Mesh or Litmus on top of Kubernetes.
- Not Selenium — k6 browser is lighter; for full QA test suites against UI, Playwright / Cypress fit better.
For "how does this API hold up at 1000 RPS?" k6 is the right tool.