Load Testing with k6: A Practical Guide
A hands-on guide to load testing Node.js applications with k6 covering test scripts, scenarios, thresholds, custom metrics, and CI/CD integration.
Load Testing with k6: A Practical Guide
Load testing answers the question every developer avoids until production fails: how many users can this application handle before response times become unacceptable?
k6 is an open-source load testing tool that uses JavaScript for test scripts. Unlike tools that require a separate language or GUI, k6 scripts look like the JavaScript you already write. You define virtual users, ramp them up, and measure how your application behaves under pressure.
Prerequisites
- k6 installed (k6.io/docs/getting-started/installation)
- A Node.js application to test (running locally or on a staging server)
- Basic JavaScript knowledge
Installing k6
# macOS
brew install k6
# Windows (with Chocolatey)
choco install k6
# Linux (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 C5AD17C747E3415A3642D57D77C6C491D6AC1D68
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-get update && sudo apt-get install k6
# Docker
docker run --rm -i grafana/k6 run - < script.js
Verify installation:
k6 version
Your First Load Test
// load-test.js
import http from "k6/http";
import { check, sleep } from "k6";
export var options = {
vus: 10, // 10 virtual users
duration: "30s" // for 30 seconds
};
export default function() {
var res = http.get("http://localhost:3000/api/users");
check(res, {
"status is 200": function(r) { return r.status === 200; },
"response time < 500ms": function(r) { return r.timings.duration < 500; }
});
sleep(1); // Wait 1 second between requests (simulates think time)
}
k6 run load-test.js
Output:
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: load-test.js
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration
* default: 10 looping VUs for 30s
✓ status is 200
✓ response time < 500ms
checks.....................: 100.00% ✓ 580 ✗ 0
data_received..............: 1.2 MB 40 kB/s
data_sent..................: 52 kB 1.7 kB/s
http_req_duration..........: avg=45ms min=12ms med=38ms max=210ms p(90)=78ms p(95)=95ms
http_reqs..................: 290 9.67/s
iteration_duration.........: avg=1.05s min=1.01s med=1.04s max=1.21s p(90)=1.08s p(95)=1.1s
iterations.................: 290 9.67/s
vus........................: 10 min=10 max=10
vus_max....................: 10 min=10 max=10
Key metrics:
- http_req_duration — how long requests take (avg, median, p90, p95)
- http_reqs — total requests and requests per second
- checks — how many assertions passed/failed
Load Test Scenarios
Ramp Up and Down
Simulate realistic traffic patterns where users arrive gradually:
import http from "k6/http";
import { check, sleep } from "k6";
export var options = {
stages: [
{ duration: "1m", target: 20 }, // Ramp up to 20 users over 1 minute
{ duration: "3m", target: 20 }, // Stay at 20 users for 3 minutes
{ duration: "1m", target: 50 }, // Ramp up to 50 users
{ duration: "3m", target: 50 }, // Stay at 50 users
{ duration: "2m", target: 0 } // Ramp down to 0
]
};
export default function() {
var res = http.get("http://localhost:3000/api/users");
check(res, {
"status is 200": function(r) { return r.status === 200; }
});
sleep(Math.random() * 3 + 1); // Random 1-4 second think time
}
Stress Test
Push the application past its limits to find the breaking point:
import http from "k6/http";
import { check, sleep } from "k6";
export var options = {
stages: [
{ duration: "2m", target: 50 }, // Normal load
{ duration: "2m", target: 100 }, // Above normal
{ duration: "2m", target: 200 }, // High load
{ duration: "2m", target: 300 }, // Stress
{ duration: "2m", target: 400 }, // Breaking point?
{ duration: "5m", target: 0 } // Recovery
]
};
export default function() {
var res = http.get("http://localhost:3000/api/users");
check(res, {
"status is 200": function(r) { return r.status === 200; },
"response time < 2s": function(r) { return r.timings.duration < 2000; }
});
sleep(1);
}
Watch for:
- Response times increasing dramatically at a specific VU count
- Error rates jumping from 0% to significant percentages
- The recovery phase — does the application return to normal after load decreases?
Spike Test
Simulate a sudden burst of traffic (flash sale, viral tweet):
import http from "k6/http";
import { check, sleep } from "k6";
export var options = {
stages: [
{ duration: "30s", target: 10 }, // Normal baseline
{ duration: "10s", target: 500 }, // Sudden spike
{ duration: "1m", target: 500 }, // Sustained spike
{ duration: "10s", target: 10 }, // Drop back
{ duration: "1m", target: 10 } // Recovery
]
};
export default function() {
var res = http.get("http://localhost:3000/");
check(res, {
"status is not 500": function(r) { return r.status !== 500; },
"response time < 5s": function(r) { return r.timings.duration < 5000; }
});
sleep(0.5);
}
Soak Test
Run at moderate load for hours to find memory leaks and resource exhaustion:
import http from "k6/http";
import { check, sleep } from "k6";
export var options = {
stages: [
{ duration: "5m", target: 30 }, // Ramp up
{ duration: "4h", target: 30 }, // Sustained load for 4 hours
{ duration: "5m", target: 0 } // Ramp down
]
};
export default function() {
var res = http.get("http://localhost:3000/api/users");
check(res, {
"status is 200": function(r) { return r.status === 200; }
});
sleep(2);
}
Monitor your application's memory usage and database connection pool during soak tests. If memory grows continuously, you have a leak.
Thresholds
Thresholds define pass/fail criteria. If any threshold fails, k6 exits with a non-zero code — perfect for CI/CD.
import http from "k6/http";
import { check, sleep } from "k6";
export var options = {
vus: 50,
duration: "2m",
thresholds: {
// 95% of requests must complete below 500ms
"http_req_duration": ["p(95)<500"],
// 99% of requests must complete below 1500ms
"http_req_duration": ["p(95)<500", "p(99)<1500"],
// Less than 1% of requests can fail
"http_req_failed": ["rate<0.01"],
// All checks must pass
"checks": ["rate>0.99"],
// Custom threshold on tagged requests
"http_req_duration{name:api_users}": ["p(95)<300"],
"http_req_duration{name:api_search}": ["p(95)<800"]
}
};
export default function() {
var usersRes = http.get("http://localhost:3000/api/users", {
tags: { name: "api_users" }
});
check(usersRes, {
"users status 200": function(r) { return r.status === 200; }
});
var searchRes = http.get("http://localhost:3000/api/search?q=test", {
tags: { name: "api_search" }
});
check(searchRes, {
"search status 200": function(r) { return r.status === 200; }
});
sleep(1);
}
Testing Multiple Endpoints
Realistic User Flow
import http from "k6/http";
import { check, group, sleep } from "k6";
export var options = {
vus: 20,
duration: "3m",
thresholds: {
"http_req_duration": ["p(95)<500"],
"checks": ["rate>0.95"]
}
};
export default function() {
// Simulate a realistic user session
group("Homepage", function() {
var res = http.get("http://localhost:3000/");
check(res, {
"homepage loads": function(r) { return r.status === 200; }
});
sleep(2);
});
group("Browse articles", function() {
var res = http.get("http://localhost:3000/api/articles?page=1");
check(res, {
"articles load": function(r) { return r.status === 200; },
"returns articles": function(r) {
var body = JSON.parse(r.body);
return body.length > 0;
}
});
sleep(3);
});
group("Read article", function() {
var res = http.get("http://localhost:3000/api/articles/1");
check(res, {
"article loads": function(r) { return r.status === 200; }
});
sleep(5); // User reads the article
});
group("Search", function() {
var res = http.get("http://localhost:3000/api/search?q=nodejs");
check(res, {
"search works": function(r) { return r.status === 200; }
});
sleep(2);
});
}
Testing POST Endpoints
import http from "k6/http";
import { check, sleep } from "k6";
export var options = {
vus: 10,
duration: "1m"
};
export default function() {
// POST with JSON body
var payload = JSON.stringify({
name: "Test User " + __VU + "-" + __ITER,
email: "test" + __VU + "_" + __ITER + "@example.com"
});
var params = {
headers: {
"Content-Type": "application/json"
}
};
var res = http.post("http://localhost:3000/api/users", payload, params);
check(res, {
"created successfully": function(r) { return r.status === 201; },
"has user id": function(r) {
var body = JSON.parse(r.body);
return body.id !== undefined;
}
});
sleep(1);
}
Testing Authenticated Endpoints
import http from "k6/http";
import { check, sleep } from "k6";
export var options = {
vus: 20,
duration: "2m"
};
export function setup() {
// Login once during setup — shared across all VUs
var loginRes = http.post("http://localhost:3000/api/login", JSON.stringify({
email: "[email protected]",
password: "testpassword"
}), {
headers: { "Content-Type": "application/json" }
});
var body = JSON.parse(loginRes.body);
return { token: body.token };
}
export default function(data) {
var params = {
headers: {
"Authorization": "Bearer " + data.token,
"Content-Type": "application/json"
}
};
var res = http.get("http://localhost:3000/api/profile", params);
check(res, {
"authenticated request succeeds": function(r) { return r.status === 200; },
"returns user data": function(r) {
var body = JSON.parse(r.body);
return body.email !== undefined;
}
});
sleep(1);
}
Custom Metrics
import http from "k6/http";
import { check, sleep } from "k6";
import { Counter, Gauge, Rate, Trend } from "k6/metrics";
// Custom metrics
var errorCount = new Counter("custom_errors");
var responseSize = new Trend("custom_response_size");
var successRate = new Rate("custom_success_rate");
var activeUsers = new Gauge("custom_active_users");
export var options = {
vus: 30,
duration: "2m",
thresholds: {
"custom_errors": ["count<50"],
"custom_success_rate": ["rate>0.95"],
"custom_response_size": ["p(95)<50000"]
}
};
export default function() {
activeUsers.add(__VU);
var res = http.get("http://localhost:3000/api/articles");
if (res.status !== 200) {
errorCount.add(1);
successRate.add(false);
} else {
successRate.add(true);
}
responseSize.add(res.body.length);
check(res, {
"status is 200": function(r) { return r.status === 200; }
});
sleep(1);
}
Parameterized Tests with Test Data
import http from "k6/http";
import { check, sleep } from "k6";
import { SharedArray } from "k6/data";
// Load test data — parsed once, shared across VUs (memory efficient)
var users = new SharedArray("users", function() {
return [
{ email: "[email protected]", password: "pass123" },
{ email: "[email protected]", password: "pass456" },
{ email: "[email protected]", password: "pass789" },
{ email: "[email protected]", password: "pass012" },
{ email: "[email protected]", password: "pass345" }
];
});
var searchTerms = new SharedArray("searches", function() {
return ["nodejs", "express", "api", "testing", "docker", "kubernetes",
"javascript", "database", "security", "performance"];
});
export var options = {
vus: 20,
duration: "2m"
};
export default function() {
// Each VU uses a different user
var user = users[__VU % users.length];
// Each iteration uses a different search term
var term = searchTerms[__ITER % searchTerms.length];
var loginRes = http.post("http://localhost:3000/api/login", JSON.stringify({
email: user.email,
password: user.password
}), {
headers: { "Content-Type": "application/json" }
});
check(loginRes, {
"login successful": function(r) { return r.status === 200; }
});
if (loginRes.status === 200) {
var token = JSON.parse(loginRes.body).token;
var searchRes = http.get("http://localhost:3000/api/search?q=" + term, {
headers: { "Authorization": "Bearer " + token }
});
check(searchRes, {
"search returns results": function(r) { return r.status === 200; }
});
}
sleep(2);
}
Outputting Results
JSON Output
k6 run --out json=results.json load-test.js
CSV Output
k6 run --out csv=results.csv load-test.js
InfluxDB for Dashboards
k6 run --out influxdb=http://localhost:8086/k6 load-test.js
Combine with Grafana for real-time dashboards showing requests per second, response times, and error rates during the test.
Summary Export
import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.1/index.js";
export function handleSummary(data) {
return {
"stdout": textSummary(data, { indent: " ", enableColors: true }),
"summary.json": JSON.stringify(data)
};
}
CI/CD Integration
GitHub Actions
name: Load Tests
on:
push:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Weekly on Monday at 6 AM
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- name: Start application
run: npm start &
env:
NODE_ENV: test
PORT: 3000
- name: Wait for app to start
run: |
for i in $(seq 1 30); do
curl -s http://localhost:3000/health && break
sleep 1
done
- name: Install k6
run: |
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
--keyserver hkp://keyserver.ubuntu.com:80 \
--recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D68
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-get update && sudo apt-get install k6
- name: Run load tests
run: k6 run --out json=results.json tests/load-test.js
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results.json
Comparing Results Between Commits
// compare-results.js
var fs = require("fs");
var baseline = JSON.parse(fs.readFileSync("baseline-results.json", "utf8"));
var current = JSON.parse(fs.readFileSync("current-results.json", "utf8"));
function getMetric(data, name) {
// Parse k6 JSON output line by line
var lines = data.split("\n").filter(function(l) { return l.trim(); });
var values = [];
for (var i = 0; i < lines.length; i++) {
try {
var entry = JSON.parse(lines[i]);
if (entry.metric === name && entry.type === "Point") {
values.push(entry.data.value);
}
} catch (e) {}
}
values.sort(function(a, b) { return a - b; });
return {
median: values[Math.floor(values.length * 0.5)],
p95: values[Math.floor(values.length * 0.95)],
p99: values[Math.floor(values.length * 0.99)]
};
}
var baselineMetrics = getMetric(baseline, "http_req_duration");
var currentMetrics = getMetric(current, "http_req_duration");
console.log("Response Time Comparison:");
console.log(" Median: " + baselineMetrics.median + "ms -> " + currentMetrics.median + "ms");
console.log(" p95: " + baselineMetrics.p95 + "ms -> " + currentMetrics.p95 + "ms");
console.log(" p99: " + baselineMetrics.p99 + "ms -> " + currentMetrics.p99 + "ms");
var regression = currentMetrics.p95 / baselineMetrics.p95;
if (regression > 1.2) {
console.log("\nWARNING: p95 regressed by " + ((regression - 1) * 100).toFixed(1) + "%");
process.exit(1);
}
Complete Working Example: Full Test Suite
// full-load-test.js
import http from "k6/http";
import { check, group, sleep } from "k6";
import { Counter, Rate, Trend } from "k6/metrics";
// Custom metrics
var errors = new Counter("custom_errors");
var successRate = new Rate("custom_success_rate");
var apiDuration = new Trend("api_duration");
export var options = {
scenarios: {
// Scenario 1: Constant browsing traffic
browsers: {
executor: "constant-vus",
vus: 20,
duration: "5m",
exec: "browseArticles"
},
// Scenario 2: Ramping API traffic
api_clients: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "1m", target: 10 },
{ duration: "3m", target: 30 },
{ duration: "1m", target: 0 }
],
exec: "apiRequests"
},
// Scenario 3: Burst of search traffic
searchers: {
executor: "ramping-arrival-rate",
startRate: 1,
timeUnit: "1s",
preAllocatedVUs: 50,
stages: [
{ duration: "1m", target: 5 },
{ duration: "2m", target: 20 },
{ duration: "1m", target: 5 },
{ duration: "1m", target: 0 }
],
exec: "searchTraffic"
}
},
thresholds: {
"http_req_duration": ["p(95)<500", "p(99)<1500"],
"http_req_failed": ["rate<0.05"],
"custom_success_rate": ["rate>0.95"],
"api_duration": ["p(95)<300"]
}
};
var BASE_URL = "http://localhost:3000";
export function browseArticles() {
group("Browse flow", function() {
var res = http.get(BASE_URL + "/");
check(res, { "homepage ok": function(r) { return r.status === 200; } });
sleep(3);
res = http.get(BASE_URL + "/library");
check(res, { "library ok": function(r) { return r.status === 200; } });
sleep(5);
res = http.get(BASE_URL + "/library?page=2");
check(res, { "page 2 ok": function(r) { return r.status === 200; } });
sleep(4);
});
}
export function apiRequests() {
var start = Date.now();
var res = http.get(BASE_URL + "/library/api/articles?page=1&limit=20", {
headers: { "Accept": "application/json" }
});
apiDuration.add(Date.now() - start);
var passed = check(res, {
"api status 200": function(r) { return r.status === 200; },
"api returns json": function(r) {
return r.headers["Content-Type"] &&
r.headers["Content-Type"].indexOf("json") !== -1;
}
});
successRate.add(passed);
if (!passed) errors.add(1);
sleep(1);
}
export function searchTraffic() {
var terms = ["nodejs", "express", "api", "testing", "docker"];
var term = terms[Math.floor(Math.random() * terms.length)];
var res = http.get(BASE_URL + "/library/api/articles?search=" + term);
check(res, {
"search ok": function(r) { return r.status === 200; }
});
}
k6 run full-load-test.js
Common Issues and Troubleshooting
k6 reports connection refused errors
The application is not running or cannot handle the connection rate:
Fix: Verify the application is running and the URL is correct. Check if the application limits concurrent connections. Increase the application's connection pool or worker count. Start with fewer VUs and ramp up gradually.
Response times are good locally but terrible in CI
CI runners have limited CPU and share resources with other jobs:
Fix: Run load tests against a staging server, not localhost in CI. If testing locally in CI, reduce VU count and adjust thresholds for the CI environment. Use separate threshold configurations for local and CI testing.
k6 script syntax errors with require()
k6 uses ES modules, not CommonJS:
Fix: Use import and export syntax in k6 scripts. k6 runs its own JavaScript runtime (goja), not Node.js. You cannot use require() or Node.js built-in modules. Use k6's built-in modules (k6/http, k6/metrics, etc.).
Memory usage grows during long soak tests
k6 collects metrics in memory by default:
Fix: Use --out to stream metrics to an external store (InfluxDB, JSON file) instead of keeping them in memory. For very long tests, use discardResponseBodies to reduce memory usage.
Thresholds pass but application is clearly slow
Thresholds may be too generous, or the test does not generate enough load:
Fix: Review threshold values against actual SLA requirements. Increase VU count to match expected production traffic. Add thresholds on specific endpoint tags, not just global metrics. Check that think time (sleep) is realistic.
Best Practices
- Start with a baseline. Run your first load test with a small number of VUs to establish normal response times. Use these numbers to set realistic thresholds.
- Use realistic think time. Real users do not hammer endpoints continuously. Add
sleep()calls between requests to simulate reading, typing, and navigation time. Without think time, you test an unrealistic worst case. - Test individual endpoints and full user flows. Endpoint tests find specific bottlenecks. User flow tests find interactions between endpoints (shared database connections, cache thrashing).
- Tag requests for granular thresholds. A global p95 threshold hides slow endpoints behind fast ones. Tag each request and set per-endpoint thresholds.
- Run soak tests before major releases. Short load tests miss memory leaks and connection pool exhaustion. Run at moderate load for hours to find time-dependent failures.
- Fail the build on threshold violations. k6 exits with code 99 when thresholds fail. Use this in CI/CD to prevent deploying code that does not meet performance requirements.
- Test with production-like data. An empty database responds faster than one with millions of rows. Seed your test environment with realistic data volumes.
- Keep load test scripts in version control. Load tests are code. Review them, version them, and maintain them alongside your application.