k6 Load Testing — Complete Guide for Node.js APIs 2026
Advertisement
Introduction
Why This Matters
A feature that works perfectly in development can collapse under production traffic. Load testing before launch is not optional — it is how you find bottlenecks in your database queries, memory leaks under concurrent load, and mis-tuned connection pools. k6 is the leading open-source load testing tool in 2026: scripts are written in JavaScript/TypeScript, it integrates with Grafana Cloud, and it runs efficiently from your CI pipeline or locally without a GUI.
Installing k6
# macOS
brew install k6
# Ubuntu / Debian
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
# Docker
docker run --rm -i grafana/k6 run - < script.jsYour First Load Test
// tests/load/basic.js
import http from 'k6/http';
import { check, sleep } from 'k6';
// Test configuration
export const options = {
vus: 50, // 50 concurrent virtual users
duration: '30s', // Run for 30 seconds
};
export default function () {
// Each VU runs this function in a loop for the duration
const response = http.get('http://localhost:3000/api/users');
// Assertions — check() logs pass/fail but does not stop the test
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'body is valid JSON': (r) => {
try {
JSON.parse(r.body as string);
return true;
} catch {
return false;
}
},
});
sleep(1); // Think time between requests
}Run it:
k6 run tests/load/basic.jsRamp-Up Scenarios with Stages
Real traffic does not spike instantly. Use stages to simulate gradual ramp-up, sustained load, and ramp-down.
// tests/load/ramp.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 20 }, // Ramp up to 20 VUs over 1 minute
{ duration: '3m', target: 20 }, // Hold 20 VUs for 3 minutes
{ duration: '1m', target: 100 }, // Spike to 100 VUs
{ duration: '2m', target: 100 }, // Hold the spike
{ duration: '1m', target: 0 }, // Ramp down to 0
],
thresholds: {
// Test fails if these are not met
http_req_duration: ['p(95)<500', 'p(99)<1000'], // 95th percentile < 500ms
http_req_failed: ['rate<0.01'], // Error rate < 1%
checks: ['rate>0.99'], // 99%+ checks pass
},
};
export default function () {
const res = http.get('http://localhost:3000/api/posts');
check(res, {
'OK': (r) => r.status === 200,
});
sleep(Math.random() * 2 + 1); // Random 1-3s think time
}Testing Authenticated Endpoints
// tests/load/auth.js
import http from 'k6/http';
import { check } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
export const options = {
vus: 20,
duration: '1m',
thresholds: {
http_req_duration: ['p(95)<800'],
http_req_failed: ['rate<0.05'],
},
};
// setup() runs once before the test — use for auth token retrieval
export function setup() {
const loginRes = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ email: 'test@example.com', password: 'test-password' }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(loginRes, { 'login succeeded': (r) => r.status === 200 });
const body = JSON.parse(loginRes.body as string);
return { token: body.accessToken };
}
// data from setup() is passed to the default function
export default function (data: { token: string }) {
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${data.token}`,
};
// GET protected resource
const profileRes = http.get(`${BASE_URL}/api/me`, { headers });
check(profileRes, { 'profile OK': (r) => r.status === 200 });
// POST mutation
const postRes = http.post(
`${BASE_URL}/api/posts`,
JSON.stringify({ title: `Load test post ${Date.now()}`, body: 'Test content' }),
{ headers }
);
check(postRes, { 'create post OK': (r) => r.status === 201 });
}Advanced: Multiple Scenarios
k6 scenarios let you run different user behaviors simultaneously in the same test.
// tests/load/scenarios.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
// Read-heavy workload: 100 VUs constantly reading
read_heavy: {
executor: 'constant-vus',
vus: 100,
duration: '5m',
exec: 'readFlow',
},
// Write workload: 10 VUs writing
write_workload: {
executor: 'constant-vus',
vus: 10,
duration: '5m',
exec: 'writeFlow',
},
// Spike test: ramp to 500 VUs instantly
spike: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 500 },
{ duration: '1m', target: 500 },
{ duration: '30s', target: 0 },
],
startTime: '4m', // Start spike at minute 4
exec: 'readFlow',
},
},
thresholds: {
'http_req_duration{scenario:read_heavy}': ['p(95)<300'],
'http_req_duration{scenario:write_workload}': ['p(95)<1000'],
'http_req_failed': ['rate<0.01'],
},
};
export function readFlow() {
const res = http.get('http://localhost:3000/api/posts?page=1&limit=20');
check(res, { 'read OK': (r) => r.status === 200 });
sleep(1);
}
export function writeFlow() {
const res = http.post(
'http://localhost:3000/api/posts',
JSON.stringify({ title: 'Test', body: 'Load test' }),
{ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' } }
);
check(res, { 'write OK': (r) => r.status === 201 });
sleep(3);
}Reading k6 Output
✓ status is 200
✓ response time < 500ms
checks.........................: 99.80% ✓ 5988 ✗ 12
data_received..................: 4.1 MB 68 kB/s
data_sent......................: 890 kB 15 kB/s
http_req_blocked...............: avg=1.11ms min=1µs med=4µs max=165ms
http_req_duration..............: avg=214ms min=52ms med=198ms max=1.21s
{ expected_response:true }...: avg=214ms min=52ms med=198ms max=1.21s
✓ http_req_duration.............: p(95)=487ms p(99)=810ms
✓ http_req_failed...............: 0.19% ✓ 5988 ✗ 12
http_reqs......................: 6000 100/s
iteration_duration.............: avg=1.21s
vus............................: 50 min=50 max=50Key metrics to watch: p(95) and p(99) for latency, http_req_failed for error rate, and http_reqs for throughput.
Integrating k6 into CI/CD
# .github/workflows/load-test.yml
name: Load Test
on:
push:
branches: [main]
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install k6
run: |
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 -y k6
- name: Start API (staging)
run: docker compose up -d && sleep 10
- name: Run load test
run: k6 run --out json=results.json tests/load/ramp.js
env:
BASE_URL: http://localhost:3000
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results.jsonCommon Mistakes
Mistake 1 — Not using sleep(): without think time, every VU hammers the API as fast as possible, which does not represent real user behavior and produces unrealistic results.
Mistake 2 — Testing in production without rate limits: always load test in a staging environment — an uncapped k6 test can take down a production service.
Mistake 3 — Ignoring the 99th percentile: p(95) looks fine but p(99) at 5 seconds means 1 in 100 users gets a terrible experience. Always check p(99).
Mistake 4 — Not warming up the DB and caches before measuring: the first few seconds of a load test populate caches and warm JIT — ramp-up stages give you more representative steady-state numbers.
Best Practices
- Define thresholds in every test script — use them as pass/fail gates in your CI pipeline.
- Use
__ENV.BASE_URLto make scripts portable across local, staging, and cloud environments. - Store baseline results (JSON output) and compare against them in CI to detect regressions.
- Monitor your Node.js process during load tests using
clinic.jsor the built-in--profflag to correlate CPU and memory with k6 metrics. - Use k6 Cloud or Grafana Cloud k6 for distributed load tests that require more than one machine's network bandwidth.
Key Takeaways
- k6 load test scripts are plain JavaScript — no GUI, no XML config, version-controllable alongside your application code.
thresholdsturn load test results into CI pass/fail gates based on p(95) latency, error rate, and check pass rate.- The
stagesexecutor simulates realistic traffic ramp-up, sustained load, and spike scenarios in a single test run. setup()runs once before all VUs start — use it to authenticate and pass tokens to VU functions via its return value.- Multiple
scenarioslet you simulate mixed read/write workloads and spike tests simultaneously in a single k6 run. - Always include
sleep()in VU functions to simulate realistic user think time between actions. - The p(99) latency metric is the most important for user experience — p(95) alone can hide tail latency problems.
- Never run uncapped load tests against production — always use staging environments with realistic data volumes.
Advertisement