k6: Modern Load Testing for APIs and Web Services
k6: Modern Load Testing for APIs and Web Services
Most load testing tools feel like they were designed for QA teams, not developers. k6 is different: tests are JavaScript files, results pipe cleanly into Grafana, and the CLI integrates naturally into CI pipelines.
k6 runs test scenarios that send real HTTP requests, measures response times and error rates, and reports whether your service met its performance targets. It's designed for testing at realistic scale — from a few dozen concurrent users to millions of requests per second (using k6 Cloud).
Installation
# macOS
brew install k6
# Linux (Debian/Ubuntu)
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt update && sudo apt install k6
# Docker
docker pull grafana/k6
Your First Test
Create a file test.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 10, // virtual users
duration: '30s', // test duration
};
export default function () {
const res = http.get('https://httpbin.org/get');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1); // think time between requests
}
Run it:
k6 run test.js
k6 outputs a summary with request counts, response times (median, p95, p99), failure rates, and whether checks passed.
Test Structure
Every k6 test has the same structure:
// Executed once per test (setup)
export function setup() {
return { authToken: authenticate() };
}
// Executed by each virtual user in a loop
export default function (data) {
// data comes from setup()
makeAuthenticatedRequest(data.authToken);
}
// Executed once when all VUs finish (teardown)
export function teardown(data) {
// cleanup
}
The default function is the hot path — keep it focused on the requests you're testing.
Load Patterns with Scenarios
Real traffic isn't constant. k6 scenarios model traffic patterns:
export const options = {
scenarios: {
// Ramp up, hold, ramp down
ramp_up: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 50 }, // ramp to 50 users
{ duration: '3m', target: 50 }, // hold at 50 users
{ duration: '1m', target: 0 }, // ramp down
],
},
// Fixed arrival rate (RPS)
steady_rps: {
executor: 'constant-arrival-rate',
rate: 100, // 100 requests per second
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 50, // pre-allocate VUs to handle load
},
},
};
Ramping VUs: Good for simulating user growth and finding breaking points. Constant arrival rate: Good for testing at a specific RPS target. k6 adjusts VU count to maintain the rate.
Realistic Test Scenarios
Authenticated API Calls
import http from 'k6/http';
import { check } from 'k6';
const BASE_URL = 'https://api.example.com';
export function setup() {
const loginRes = http.post(`${BASE_URL}/auth/login`, JSON.stringify({
email: '[email protected]',
password: 'password',
}), { headers: { 'Content-Type': 'application/json' } });
return { token: loginRes.json('access_token') };
}
export default function (data) {
const headers = {
'Authorization': `Bearer ${data.token}`,
'Content-Type': 'application/json',
};
// Create a resource
const createRes = http.post(`${BASE_URL}/items`, JSON.stringify({
name: 'Test Item',
value: Math.random(),
}), { headers });
check(createRes, { 'item created': (r) => r.status === 201 });
// Read it back
const id = createRes.json('id');
const getRes = http.get(`${BASE_URL}/items/${id}`, { headers });
check(getRes, { 'item retrieved': (r) => r.status === 200 });
}
Browser-like Session (Correlation)
When testing flows that depend on dynamic values (CSRF tokens, session IDs), extract them from responses:
import { parseHTML } from 'k6/html';
export default function () {
// Get login page (to extract CSRF token)
const loginPage = http.get('https://app.example.com/login');
const doc = parseHTML(loginPage.body);
const csrfToken = doc.find('input[name="csrf_token"]').attr('value');
// Submit login with token
http.post('https://app.example.com/login', {
username: '[email protected]',
password: 'password',
csrf_token: csrfToken,
});
}
Thresholds: Pass/Fail Criteria
Thresholds make k6 tests deterministic — the test passes or fails based on whether metrics met your targets:
export const options = {
thresholds: {
// 95th percentile response time under 500ms
http_req_duration: ['p(95)<500'],
// 99th percentile under 1 second
'http_req_duration{name:api_create}': ['p(99)<1000'],
// Error rate below 1%
http_req_failed: ['rate<0.01'],
// All checks must pass
checks: ['rate>0.99'],
},
};
When a threshold fails, k6 exits with code 99, causing CI to fail.
Tag requests with custom names to measure specific operations:
http.get('https://api.example.com/items', { tags: { name: 'api_list' } });
http.post('https://api.example.com/items', body, { tags: { name: 'api_create' } });
Metrics and Output
k6 collects built-in metrics automatically:
| Metric | Description |
|---|---|
http_req_duration |
Total request time (connect + send + wait + receive) |
http_req_waiting |
Time to first byte (server processing time) |
http_req_failed |
Failed request rate |
vus |
Current virtual users |
iterations |
Total test function executions |
Output to different formats:
# JSON output for processing
k6 run --out json=results.json test.js
# InfluxDB for Grafana
k6 run --out influxdb=http://localhost:8086/k6 test.js
# CSV
k6 run --out csv=results.csv test.js
Grafana Dashboard Integration
The Grafana + InfluxDB setup gives you real-time metrics visualization during tests:
- Run InfluxDB:
docker run -d -p 8086:8086 influxdb:1.8 - Import Grafana dashboard #2587 (official k6 dashboard)
- Run tests with
--out influxdb=http://localhost:8086/k6
You can watch response times, VU count, and RPS in real time as the test runs.
CI Integration
k6 exits with code 0 (pass) or non-zero (fail based on thresholds), making CI integration simple.
GitHub Actions:
name: Load Test
on: [push]
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: grafana/setup-k6-action@v1
- name: Run load test
run: k6 run --out json=results.json tests/load.js
- name: Upload results
uses: actions/upload-artifact@v3
if: always()
with:
name: k6-results
path: results.json
Recommended CI usage: Run load tests against staging, not production. Keep them fast (1-2 minutes max in CI). Reserve longer stress tests for manual runs.
Environment Variables in Tests
import { __ENV } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
const API_KEY = __ENV.API_KEY;
export default function () {
http.get(`${BASE_URL}/api/items`, {
headers: { 'X-API-Key': API_KEY },
});
}
Run with: k6 run -e BASE_URL=https://staging.example.com -e API_KEY=secret test.js
Finding Breaking Points
For stress testing (finding where your service degrades):
export const options = {
stages: [
{ duration: '2m', target: 100 },
{ duration: '5m', target: 100 },
{ duration: '2m', target: 200 },
{ duration: '5m', target: 200 },
{ duration: '2m', target: 300 },
{ duration: '5m', target: 300 },
{ duration: '2m', target: 0 },
],
thresholds: {
// These thresholds will likely fail — that's the point
// Watch where performance degrades in the Grafana dashboard
http_req_duration: ['p(99)<2000'],
http_req_failed: ['rate<0.1'],
},
};
Watch Grafana as VUs increase. The point where p95 response time spikes and error rate increases is your breaking point.
The Developer-Friendly Advantage
k6's design — JavaScript test scripts, CLI-first, CI-native, Grafana integration — makes it the right tool for development teams who want to own performance testing rather than hand it off to a specialist QA team. Tests live in the same repository as the code they test, run in the same CI pipeline, and fail builds when performance regresses.