Load Testing with k6: A Practical Guide
Complete guide to load testing Node.js applications with k6 covering scenarios, thresholds, custom metrics, authentication, CI/CD integration, and result analysis.
Load Testing with k6: A Practical Guide
Overview
k6 is an open-source load testing tool built by Grafana Labs that lets you write performance tests in JavaScript and execute them with a high-performance Go runtime. Unlike tools that bolt on scripting as an afterthought, k6 was designed from the ground up for developers who want to write load tests the same way they write application code -- with real logic, reusable modules, and version-controlled test suites. If you ship a Node.js API and you are not load testing it before production, you are just hoping it works under traffic. Hope is not a strategy.
Prerequisites
- k6 installed (k6.io/docs/getting-started/installation)
- Node.js 18+ installed
- An Express.js application to test (we will build one)
- Basic familiarity with HTTP, REST APIs, and JSON
- Docker installed (optional, for running k6 in containers)
- A GitHub or Azure DevOps account (for CI/CD integration sections)
k6 Installation and Basic Concepts
Installation
On macOS:
brew install k6
On Windows:
choco install k6
On 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-get update
sudo apt-get install k6
Or run it via Docker:
docker run --rm -i grafana/k6 run - <script.js
Verify your installation:
k6 version
k6 v0.54.0 (go1.22.5, linux/amd64)
Core Concepts
k6 revolves around three fundamental concepts:
Virtual Users (VUs) -- Each VU is an independent execution thread that runs your test script in a loop. A VU simulates a single user making requests. 50 VUs means 50 concurrent users hammering your API simultaneously.
Iterations -- One complete execution of your test function. Each VU runs multiple iterations during the test. If a VU's iteration takes 200ms, it will complete roughly 5 iterations per second.
Duration -- How long the test runs. You can specify a fixed duration (30s, 5m, 1h) or a fixed number of iterations. Duration-based tests are more common because they simulate sustained traffic patterns.
Writing Your First Load Test Script
k6 scripts use ES module syntax -- this is how k6's JavaScript runtime works, not Node.js. Create a file called load-test.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
export var options = {
vus: 10,
duration: '30s',
};
export default function () {
var res = http.get('http://localhost:3000/api/health');
check(res, {
'status is 200': function (r) { return r.status === 200; },
'response time < 200ms': function (r) { return r.timings.duration < 200; },
});
sleep(1);
}
Run it:
k6 run load-test.js
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: load-test.js
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
* default: 10 looping VUs for 30s (gracefulStop: 30s)
running (0m30.0s), 00/10 VUs, 285 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs 30s
✓ status is 200
✓ response time < 200ms
checks.........................: 100.00% ✓ 570 ✗ 0
data_received..................: 68 kB 2.3 kB/s
data_sent......................: 24 kB 812 B/s
http_req_blocked...............: avg=23µs min=1µs med=4µs max=2.1ms p(90)=7µs p(95)=11µs
http_req_connecting............: avg=14µs min=0s med=0s max=1.8ms p(90)=0s p(95)=0s
http_req_duration..............: avg=4.32ms min=1.2ms med=3.8ms max=42ms p(90)=7.1ms p(95)=9.5ms
{ expected_response:true }...: avg=4.32ms min=1.2ms med=3.8ms max=42ms p(90)=7.1ms p(95)=9.5ms
http_req_failed................: 0.00% ✓ 0 ✗ 285
http_req_receiving.............: avg=42µs min=8µs med=34µs max=1.2ms p(90)=78µs p(95)=102µs
http_req_sending...............: avg=18µs min=3µs med=14µs max=892µs p(90)=28µs p(95)=38µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=4.26ms min=1.1ms med=3.7ms max=41ms p(90)=7ms p(95)=9.3ms
http_reqs......................: 285 9.499869/s
iteration_duration.............: avg=1s min=1s med=1s max=1.04s p(90)=1.01s p(95)=1.01s
iterations.....................: 285 9.499869/s
vus............................: 10 min=10 max=10
vus_max........................: 10 min=10 max=10
The key metrics to focus on: http_req_duration is the total request time (p90 and p95 are what matter), http_req_failed is your error rate, and http_reqs is throughput. Everything else is diagnostic.
Test Lifecycle Stages
k6 provides three lifecycle hooks: setup, the default function (your actual test), and teardown. This mirrors how you structure real test suites.
import http from 'k6/http';
import { check } from 'k6';
export var options = {
vus: 20,
duration: '1m',
};
// Runs once before test starts. Return value is passed to default and teardown.
export function setup() {
var loginRes = http.post('http://localhost:3000/api/auth/login', JSON.stringify({
email: '[email protected]',
password: 'testpassword123',
}), {
headers: { 'Content-Type': 'application/json' },
});
var token = JSON.parse(loginRes.body).token;
return { token: token };
}
// Runs once per iteration per VU. `data` is the return value from setup().
export default function (data) {
var res = http.get('http://localhost:3000/api/articles', {
headers: { 'Authorization': 'Bearer ' + data.token },
});
check(res, {
'authenticated request succeeded': function (r) { return r.status === 200; },
});
}
// Runs once after all VUs finish. `data` is the return value from setup().
export function teardown(data) {
http.post('http://localhost:3000/api/auth/logout', null, {
headers: { 'Authorization': 'Bearer ' + data.token },
});
}
Important: setup() and teardown() each run exactly once, regardless of VU count. They run in a special VU that does not count toward your configured VUs. The data returned from setup() is serialized to JSON and deserialized for each VU -- you cannot pass functions or complex objects.
Load Test Scenarios
k6 scenarios give you fine-grained control over how VUs are scheduled. This is where k6 separates itself from simpler tools. Instead of one flat load profile, you can define multiple named scenarios that run concurrently with different traffic patterns.
Ramp-Up Test
Gradually increase load to find the breaking point:
import http from 'k6/http';
import { check, sleep } from 'k6';
export var options = {
stages: [
{ duration: '2m', target: 50 }, // ramp up to 50 VUs over 2 minutes
{ duration: '5m', target: 50 }, // hold at 50 VUs for 5 minutes
{ duration: '2m', target: 0 }, // ramp down to 0
],
};
export default function () {
var res = http.get('http://localhost:3000/api/articles');
check(res, {
'status is 200': function (r) { return r.status === 200; },
});
sleep(1);
}
Spike Test
Simulate a sudden burst of traffic -- a product launch, a social media mention, a DDoS:
export var options = {
stages: [
{ duration: '1m', target: 10 }, // warm up
{ duration: '10s', target: 500 }, // spike to 500 VUs in 10 seconds
{ duration: '2m', target: 500 }, // hold the spike
{ duration: '10s', target: 10 }, // drop back down
{ duration: '2m', target: 10 }, // recovery period
{ duration: '30s', target: 0 }, // ramp down
],
};
Soak Test
Run at moderate load for an extended period to detect memory leaks and connection pool exhaustion:
export var options = {
stages: [
{ duration: '5m', target: 30 }, // ramp up
{ duration: '4h', target: 30 }, // hold for 4 hours
{ duration: '5m', target: 0 }, // ramp down
],
};
Multiple Named Scenarios
Run different workloads concurrently to simulate realistic mixed traffic:
import http from 'k6/http';
import { check, sleep } from 'k6';
export var options = {
scenarios: {
browse_articles: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 30 },
{ duration: '5m', target: 30 },
{ duration: '1m', target: 0 },
],
exec: 'browseArticles',
},
create_contacts: {
executor: 'constant-arrival-rate',
rate: 5,
timeUnit: '1s',
duration: '8m',
preAllocatedVUs: 10,
maxVUs: 20,
exec: 'submitContact',
},
},
};
export function browseArticles() {
var res = http.get('http://localhost:3000/api/articles');
check(res, { 'browse 200': function (r) { return r.status === 200; } });
sleep(Math.random() * 3 + 1); // 1-4 second think time
}
export function submitContact() {
var payload = JSON.stringify({
name: 'Load Test User',
email: 'loadtest' + Math.floor(Math.random() * 10000) + '@test.com',
message: 'This is an automated load test submission.',
});
var res = http.post('http://localhost:3000/api/contact', payload, {
headers: { 'Content-Type': 'application/json' },
});
check(res, { 'contact 201': function (r) { return r.status === 201; } });
}
The constant-arrival-rate executor is particularly valuable -- it maintains a fixed request rate regardless of response time. If your server slows down, k6 spawns more VUs to maintain the target rate, which is exactly how real traffic behaves.
Testing REST APIs and Express.js Endpoints
Here is a sample Express.js API we will test against. This is Node.js code, so it uses var, require, and function() syntax:
var express = require('express');
var jwt = require('jsonwebtoken');
var app = express();
app.use(express.json());
var JWT_SECRET = 'load-test-secret-key';
var articles = [
{ id: 1, title: 'API Gateway Patterns', category: 'architecture' },
{ id: 2, title: 'Docker Multi-Stage Builds', category: 'devops' },
{ id: 3, title: 'PostgreSQL Indexing', category: 'databases' },
];
app.get('/api/health', function (req, res) {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.post('/api/auth/login', function (req, res) {
var email = req.body.email;
var password = req.body.password;
if (email === '[email protected]' && password === 'testpassword123') {
var token = jwt.sign({ email: email, role: 'tester' }, JWT_SECRET, { expiresIn: '1h' });
return res.json({ token: token });
}
res.status(401).json({ error: 'Invalid credentials' });
});
function authMiddleware(req, res, next) {
var authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
try {
var decoded = jwt.verify(authHeader.split(' ')[1], JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
res.status(403).json({ error: 'Invalid token' });
}
}
app.get('/api/articles', function (req, res) {
var category = req.query.category;
var results = category ? articles.filter(function (a) { return a.category === category; }) : articles;
res.json({ articles: results, total: results.length });
});
app.get('/api/articles/:id', function (req, res) {
var article = articles.find(function (a) { return a.id === parseInt(req.params.id); });
if (!article) return res.status(404).json({ error: 'Not found' });
res.json(article);
});
app.post('/api/articles', authMiddleware, function (req, res) {
var newArticle = {
id: articles.length + 1,
title: req.body.title,
category: req.body.category,
};
articles.push(newArticle);
res.status(201).json(newArticle);
});
app.listen(3000, function () {
console.log('Test API running on port 3000');
});
Now the k6 test that exercises these endpoints:
import http from 'k6/http';
import { check, group, sleep } from 'k6';
var BASE_URL = 'http://localhost:3000';
export var options = {
vus: 25,
duration: '2m',
};
export default function () {
group('Health Check', function () {
var res = http.get(BASE_URL + '/api/health');
check(res, {
'health status 200': function (r) { return r.status === 200; },
'health has uptime': function (r) { return JSON.parse(r.body).uptime > 0; },
});
});
group('List Articles', function () {
var res = http.get(BASE_URL + '/api/articles');
check(res, {
'articles status 200': function (r) { return r.status === 200; },
'articles returns array': function (r) { return JSON.parse(r.body).articles.length > 0; },
});
});
group('Filter by Category', function () {
var res = http.get(BASE_URL + '/api/articles?category=architecture');
check(res, {
'filtered status 200': function (r) { return r.status === 200; },
'filter returns results': function (r) { return JSON.parse(r.body).total >= 1; },
});
});
group('Get Single Article', function () {
var res = http.get(BASE_URL + '/api/articles/1');
check(res, {
'single article 200': function (r) { return r.status === 200; },
'correct article': function (r) { return JSON.parse(r.body).id === 1; },
});
});
group('404 Handling', function () {
var res = http.get(BASE_URL + '/api/articles/999');
check(res, {
'missing article 404': function (r) { return r.status === 404; },
});
});
sleep(1);
}
The group() function organizes your checks in the output and lets you see per-endpoint metrics. This is critical when you have 15 endpoints and need to figure out which one is slow.
Thresholds and Pass/Fail Criteria
Thresholds turn your load test from "let me look at the numbers" into an automated gate that passes or fails. This is what makes k6 usable in CI/CD.
import http from 'k6/http';
import { check, sleep } from 'k6';
export var options = {
stages: [
{ duration: '1m', target: 50 },
{ duration: '3m', target: 50 },
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<500'], // 95th percentile under 300ms, 99th under 500ms
http_req_failed: ['rate<0.01'], // less than 1% failed requests
http_reqs: ['rate>100'], // at least 100 requests per second throughput
checks: ['rate>0.99'], // 99% of checks must pass
'http_req_duration{name:articles}': ['p(95)<250'], // per-endpoint threshold
},
};
export default function () {
var res = http.get('http://localhost:3000/api/articles', {
tags: { name: 'articles' },
});
check(res, {
'status 200': function (r) { return r.status === 200; },
});
sleep(1);
}
When a threshold fails, k6 exits with a non-zero exit code. The output clearly marks which thresholds passed and which failed:
✓ http_req_duration.............: avg=45ms min=2ms med=38ms max=890ms p(90)=112ms p(95)=187ms p(99)=423ms
✗ http_req_failed................: 2.34% ✓ 47 ✗ 1963
✗ rate<0.01
threshold crossed: 0.023 > 0.01
✓ http_reqs......................: 2010 134.0/s
✓ checks.........................: 97.66% ✓ 1963 ✗ 47
✗ rate>0.99
threshold crossed: 0.9766 > 0.99
The exit code will be non-zero, which fails your CI pipeline. This is the behavior you want.
Custom Metrics and Tags
The built-in metrics cover the common cases, but sometimes you need to measure business-specific things: how long does the search query take versus the article fetch? What is the 95th percentile for authenticated vs. unauthenticated requests?
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter, Rate, Gauge } from 'k6/metrics';
// Custom metrics
var articleLoadTime = new Trend('article_load_time');
var articlesCreated = new Counter('articles_created');
var authSuccessRate = new Rate('auth_success_rate');
var activeArticles = new Gauge('active_articles_count');
export var options = {
vus: 20,
duration: '2m',
thresholds: {
article_load_time: ['p(95)<250'], // custom metric threshold
auth_success_rate: ['rate>0.95'], // 95% auth success
},
};
export default function () {
// Track article load time separately
var start = Date.now();
var res = http.get('http://localhost:3000/api/articles');
articleLoadTime.add(Date.now() - start);
if (res.status === 200) {
var body = JSON.parse(res.body);
activeArticles.add(body.total);
}
// Tag requests for per-endpoint analysis
var authRes = http.post('http://localhost:3000/api/auth/login',
JSON.stringify({ email: '[email protected]', password: 'testpassword123' }),
{
headers: { 'Content-Type': 'application/json' },
tags: { endpoint: 'login', type: 'auth' },
}
);
authSuccessRate.add(authRes.status === 200);
if (authRes.status === 200) {
var token = JSON.parse(authRes.body).token;
var createRes = http.post('http://localhost:3000/api/articles',
JSON.stringify({ title: 'Load Test Article ' + __ITER, category: 'testing' }),
{
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
},
tags: { endpoint: 'create_article', type: 'write' },
}
);
if (createRes.status === 201) {
articlesCreated.add(1);
}
}
sleep(1);
}
k6's four custom metric types:
- Trend -- Tracks a distribution of values (min, max, avg, percentiles). Use for response times.
- Counter -- Accumulates a total count. Use for "how many X happened."
- Rate -- Tracks a percentage (true/false). Use for success rates.
- Gauge -- Tracks a value that goes up and down. Use for queue depth, active connections.
Handling Authentication in Load Tests
JWT Token Authentication
Most Node.js APIs use JWT tokens. The pattern is: authenticate once in setup(), reuse the token across all VUs.
import http from 'k6/http';
import { check, sleep } from 'k6';
export var options = {
vus: 30,
duration: '3m',
};
export function setup() {
var res = http.post('http://localhost:3000/api/auth/login', JSON.stringify({
email: '[email protected]',
password: 'testpassword123',
}), {
headers: { 'Content-Type': 'application/json' },
});
check(res, {
'login succeeded': function (r) { return r.status === 200; },
});
if (res.status !== 200) {
throw new Error('Authentication failed in setup: ' + res.body);
}
return { token: JSON.parse(res.body).token };
}
export default function (data) {
var headers = {
'Authorization': 'Bearer ' + data.token,
'Content-Type': 'application/json',
};
var res = http.get('http://localhost:3000/api/articles', { headers: headers });
check(res, {
'authenticated 200': function (r) { return r.status === 200; },
'not 401': function (r) { return r.status !== 401; },
'not 403': function (r) { return r.status !== 403; },
});
sleep(1);
}
Cookie-Based Session Authentication
For applications that use session cookies instead of JWT:
import http from 'k6/http';
import { check } from 'k6';
export var options = {
vus: 10,
duration: '1m',
};
export default function () {
// k6 automatically manages cookies per VU with its cookie jar
var loginRes = http.post('http://localhost:3000/login', JSON.stringify({
email: '[email protected]',
password: 'testpassword123',
}), {
headers: { 'Content-Type': 'application/json' },
});
check(loginRes, {
'login 200': function (r) { return r.status === 200; },
'session cookie set': function (r) { return r.cookies['connect.sid'] !== undefined; },
});
// Subsequent requests automatically include the session cookie
var res = http.get('http://localhost:3000/api/dashboard');
check(res, {
'dashboard accessible': function (r) { return r.status === 200; },
});
}
k6 maintains a separate cookie jar per VU, so session cookies are isolated between virtual users automatically. No extra configuration needed.
Testing WebSocket Connections
If your Node.js application uses WebSockets (Socket.io, ws, etc.), k6 has native WebSocket support:
import ws from 'k6/ws';
import { check, sleep } from 'k6';
import { Counter } from 'k6/metrics';
var messagesReceived = new Counter('ws_messages_received');
var messagesSent = new Counter('ws_messages_sent');
export var options = {
vus: 50,
duration: '2m',
};
export default function () {
var url = 'ws://localhost:3000/ws';
var res = ws.connect(url, {}, function (socket) {
socket.on('open', function () {
// Send a message every 2 seconds
socket.setInterval(function () {
socket.send(JSON.stringify({
type: 'ping',
timestamp: Date.now(),
}));
messagesSent.add(1);
}, 2000);
});
socket.on('message', function (msg) {
messagesReceived.add(1);
var data = JSON.parse(msg);
check(data, {
'message has type': function (d) { return d.type !== undefined; },
'response time < 100ms': function (d) {
return d.timestamp ? (Date.now() - d.timestamp) < 100 : true;
},
});
});
socket.on('error', function (e) {
console.error('WebSocket error:', e.error());
});
socket.on('close', function () {
// Connection closed
});
// Keep the connection open for 30 seconds
socket.setTimeout(function () {
socket.close();
}, 30000);
});
check(res, {
'ws status is 101': function (r) { return r && r.status === 101; },
});
sleep(1);
}
This test opens 50 concurrent WebSocket connections, sends a message every 2 seconds on each, and validates responses. It is an effective way to test Socket.io or ws-based real-time features under load.
CI/CD Integration
Load tests only matter if they run automatically. Here is how to wire k6 into your CI pipelines.
GitHub Actions
name: Load Test
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
load-test:
runs-on: ubuntu-latest
services:
app:
image: node:20
ports:
- 3000:3000
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Start application
run: |
npm start &
sleep 5
- 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 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-get update
sudo apt-get install k6
- name: Run load tests
run: k6 run --out json=results.json tests/load/api-load-test.js
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results.json
Azure Pipelines
trigger:
branches:
include:
- master
pool:
vmImage: 'ubuntu-latest'
steps:
- task: NodeTool@0
inputs:
versionSpec: '20.x'
displayName: 'Install Node.js'
- script: npm ci
displayName: 'Install dependencies'
- script: |
npm start &
sleep 5
displayName: 'Start application'
- script: |
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-get update
sudo apt-get install k6
displayName: 'Install k6'
- script: k6 run --out json=$(Build.ArtifactStagingDirectory)/results.json tests/load/api-load-test.js
displayName: 'Run load tests'
- task: PublishBuildArtifacts@1
condition: always()
inputs:
pathToPublish: '$(Build.ArtifactStagingDirectory)/results.json'
artifactName: 'k6-results'
displayName: 'Publish results'
Docker-Based CI
If you cannot install k6 natively, use the official Docker image:
docker run --rm --network=host \
-v $(pwd)/tests:/tests \
grafana/k6 run /tests/load/api-load-test.js
The --network=host flag is critical -- without it, the k6 container cannot reach localhost:3000 on the host machine.
Analyzing Results and Identifying Bottlenecks
JSON Output
Export results to JSON for post-processing:
k6 run --out json=results.json load-test.js
Each line in the output file is a JSON object representing a metric data point:
{"type":"Point","data":{"time":"2026-02-08T10:15:30.123Z","value":42.5,"tags":{"name":"http://localhost:3000/api/articles","method":"GET","status":"200","endpoint":"articles"}},"metric":"http_req_duration","type":"Point"}
CSV Output
For spreadsheet analysis:
k6 run --out csv=results.csv load-test.js
InfluxDB + Grafana
For real-time dashboards during long-running tests, pipe results to InfluxDB:
k6 run --out influxdb=http://localhost:8086/k6 load-test.js
Then import the k6 Grafana dashboard (dashboard ID 2587) to visualize results in real time. This setup is invaluable for soak tests where you need to watch metrics evolve over hours.
What to Look For
When analyzing k6 results, focus on these signals:
p95 vs p99 divergence -- If your p95 is 50ms but your p99 is 800ms, you have a tail latency problem. This usually means a downstream service (database query, external API) occasionally stalls. In Node.js, this is often a slow MongoDB query or an unoptimized PostgreSQL query missing an index.
Error rate correlation with VU count -- If errors only appear above 100 VUs, you are likely hitting a connection pool limit, file descriptor limit, or event loop saturation. Check your database connection pool size and your OS ulimit -n setting.
Response time increasing linearly with VUs -- This means you have a serialization bottleneck. In Node.js, common culprits are synchronous JSON parsing of large payloads, CPU-intensive template rendering, or a single-connection database client (instead of a pool).
Throughput plateau -- If requests per second stops increasing even as you add more VUs, your application has hit its ceiling. Profile the Node.js process with --prof or use clinic to identify whether it is CPU-bound (event loop), I/O-bound (database), or memory-bound.
Complete Working Example
Here is a comprehensive k6 test suite for the Express API defined earlier. It tests multiple scenarios concurrently, uses custom metrics, sets thresholds, and handles authentication.
import http from 'k6/http';
import { check, group, sleep, fail } from 'k6';
import { Trend, Counter, Rate } from 'k6/metrics';
// ---------------------
// Custom Metrics
// ---------------------
var loginDuration = new Trend('login_duration');
var articleListDuration = new Trend('article_list_duration');
var articleCreateDuration = new Trend('article_create_duration');
var failedLogins = new Counter('failed_logins');
var successRate = new Rate('overall_success_rate');
// ---------------------
// Configuration
// ---------------------
var BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
var TEST_EMAIL = __ENV.TEST_EMAIL || '[email protected]';
var TEST_PASSWORD = __ENV.TEST_PASSWORD || 'testpassword123';
export var options = {
scenarios: {
// Scenario 1: Ramp-up to steady state
steady_state: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 30 }, // ramp up
{ duration: '3m', target: 30 }, // steady state
{ duration: '1m', target: 0 }, // ramp down
],
exec: 'browseFlow',
tags: { scenario: 'steady_state' },
},
// Scenario 2: Spike test
spike: {
executor: 'ramping-vus',
startVUs: 0,
startTime: '5m30s', // start after steady_state finishes
stages: [
{ duration: '30s', target: 5 }, // baseline
{ duration: '15s', target: 150 }, // spike
{ duration: '1m', target: 150 }, // hold spike
{ duration: '15s', target: 5 }, // drop
{ duration: '1m', target: 5 }, // recovery
{ duration: '30s', target: 0 }, // done
],
exec: 'browseFlow',
tags: { scenario: 'spike' },
},
// Scenario 3: Authenticated write operations at constant rate
write_operations: {
executor: 'constant-arrival-rate',
rate: 10, // 10 iterations per second
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 15,
maxVUs: 50,
exec: 'writeFlow',
tags: { scenario: 'writes' },
},
},
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.05'],
checks: ['rate>0.95'],
login_duration: ['p(95)<400'],
article_list_duration: ['p(95)<300'],
article_create_duration: ['p(95)<500'],
overall_success_rate: ['rate>0.95'],
failed_logins: ['count<10'],
},
};
// ---------------------
// Setup: Authenticate once
// ---------------------
export function setup() {
var loginPayload = JSON.stringify({
email: TEST_EMAIL,
password: TEST_PASSWORD,
});
var res = http.post(BASE_URL + '/api/auth/login', loginPayload, {
headers: { 'Content-Type': 'application/json' },
});
if (res.status !== 200) {
fail('Setup authentication failed: ' + res.status + ' ' + res.body);
}
var token = JSON.parse(res.body).token;
console.log('Setup complete. Token acquired. Starting load test...');
return {
token: token,
baseUrl: BASE_URL,
};
}
// ---------------------
// Scenario: Browse Flow (read-heavy)
// ---------------------
export function browseFlow(data) {
group('01 - Health Check', function () {
var res = http.get(data.baseUrl + '/api/health', {
tags: { endpoint: 'health' },
});
successRate.add(res.status === 200);
check(res, {
'health 200': function (r) { return r.status === 200; },
});
});
group('02 - List All Articles', function () {
var start = Date.now();
var res = http.get(data.baseUrl + '/api/articles', {
tags: { endpoint: 'articles_list' },
});
articleListDuration.add(Date.now() - start);
successRate.add(res.status === 200);
check(res, {
'articles 200': function (r) { return r.status === 200; },
'articles has data': function (r) {
var body = JSON.parse(r.body);
return body.articles && body.articles.length > 0;
},
});
});
group('03 - Filter Articles', function () {
var categories = ['architecture', 'devops', 'databases'];
var category = categories[Math.floor(Math.random() * categories.length)];
var res = http.get(data.baseUrl + '/api/articles?category=' + category, {
tags: { endpoint: 'articles_filter' },
});
successRate.add(res.status === 200);
check(res, {
'filtered 200': function (r) { return r.status === 200; },
});
});
group('04 - Get Individual Article', function () {
var articleId = Math.floor(Math.random() * 3) + 1;
var res = http.get(data.baseUrl + '/api/articles/' + articleId, {
tags: { endpoint: 'article_detail' },
});
successRate.add(res.status === 200);
check(res, {
'article detail 200': function (r) { return r.status === 200; },
'article has title': function (r) {
return JSON.parse(r.body).title !== undefined;
},
});
});
// Simulate user think time between page views
sleep(Math.random() * 2 + 0.5);
}
// ---------------------
// Scenario: Write Flow (authenticated, write-heavy)
// ---------------------
export function writeFlow(data) {
group('Auth - Login', function () {
var start = Date.now();
var loginRes = http.post(data.baseUrl + '/api/auth/login', JSON.stringify({
email: TEST_EMAIL,
password: TEST_PASSWORD,
}), {
headers: { 'Content-Type': 'application/json' },
tags: { endpoint: 'login' },
});
loginDuration.add(Date.now() - start);
var loginOk = check(loginRes, {
'login 200': function (r) { return r.status === 200; },
'login has token': function (r) {
try { return JSON.parse(r.body).token !== undefined; }
catch (e) { return false; }
},
});
if (!loginOk) {
failedLogins.add(1);
successRate.add(false);
return;
}
var token = JSON.parse(loginRes.body).token;
group('Write - Create Article', function () {
var createStart = Date.now();
var createRes = http.post(data.baseUrl + '/api/articles', JSON.stringify({
title: 'Load Test Article VU' + __VU + ' Iter' + __ITER,
category: 'testing',
}), {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
},
tags: { endpoint: 'create_article' },
});
articleCreateDuration.add(Date.now() - createStart);
successRate.add(createRes.status === 201);
check(createRes, {
'create 201': function (r) { return r.status === 201; },
'created article has id': function (r) {
try { return JSON.parse(r.body).id !== undefined; }
catch (e) { return false; }
},
});
});
});
}
// ---------------------
// Teardown
// ---------------------
export function teardown(data) {
console.log('Load test complete. Review results above.');
}
Run it with environment variables:
k6 run --env BASE_URL=http://localhost:3000 comprehensive-test.js
Expected output for a healthy API:
scenarios: (100.00%) 3 scenarios, 200 max VUs, 9m30s max duration (incl. graceful stop):
* steady_state: Up to 30 looping VUs for 5m0s over 3 stages (exec: browseFlow)
* write_operations: 10.00 iterations/s for 5m0s (maxVUs: 15-50, exec: writeFlow)
* spike: Up to 150 looping VUs for 3m30s over 6 stages (exec: browseFlow, startTime: 5m30s)
running (09m00.0s), 000/200 VUs, 14523 complete and 0 interrupted iterations
✓ health 200
✓ articles 200
✓ articles has data
✓ filtered 200
✓ article detail 200
✓ article has title
✓ login 200
✓ login has token
✓ create 201
✓ created article has id
✓ article_create_duration........: avg=18ms min=4ms med=14ms p(95)=42ms p(99)=87ms
✓ article_list_duration..........: avg=8ms min=1ms med=6ms p(95)=22ms p(99)=45ms
✓ checks.........................: 100.00% ✓ 43569 ✗ 0
✓ failed_logins..................: 0 0/s
✓ http_req_duration..............: avg=12ms min=0ms med=8ms p(95)=35ms p(99)=78ms
✓ http_req_failed................: 0.00% ✓ 0 ✗ 29046
✓ login_duration.................: avg=15ms min=3ms med=12ms p(95)=38ms p(99)=72ms
✓ overall_success_rate...........: 100.00% ✓ 29046 ✗ 0
Common Issues and Troubleshooting
1. "dial tcp: lookup localhost: no such host" in Docker
ERRO[0001] GoError: dial tcp: lookup localhost on 127.0.0.11:53: no such host
This happens when you run k6 inside a Docker container and your target URL is localhost. The container's localhost is itself, not the host machine. Fix it with --network=host on Linux, or use host.docker.internal on macOS/Windows:
# Linux
docker run --rm --network=host -v $(pwd):/scripts grafana/k6 run /scripts/test.js
# macOS / Windows
# Change BASE_URL to http://host.docker.internal:3000 in your script
k6 run --env BASE_URL=http://host.docker.internal:3000 test.js
2. "WARN: Insufficient VUs" with constant-arrival-rate
WARN[0045] Insufficient VUs, reached 50 active VUs and cannot initialize more
executor=constant-arrival-rate scenario=write_operations
This means k6 needs more VUs to maintain your target request rate because responses are slow. Your server is falling behind. Either increase maxVUs in the scenario config, or accept that your application cannot handle the target throughput:
write_operations: {
executor: 'constant-arrival-rate',
rate: 10,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 20,
maxVUs: 100, // increase this
exec: 'writeFlow',
},
3. "request timeout" errors under moderate load
WARN[0120] Request Failed error="Get \"http://localhost:3000/api/articles\": request timeout"
k6's default request timeout is 60 seconds. If you are hitting this at modest VU counts (under 100), your application is stalling. Common causes in Node.js:
- Database connection pool exhausted -- Your pool has 10 connections but 50 VUs are waiting for a query. Increase pool size or add connection timeout handling.
- Event loop blocked -- A synchronous operation (large JSON.parse, bcrypt hashing, image processing) is blocking the event loop. Use
clinic doctorto identify the bottleneck. - File descriptor limit -- On Linux, check
ulimit -n. Default is often 1024, which is too low for load testing. Set it to at least 65535.
You can also adjust k6's timeout:
var res = http.get('http://localhost:3000/api/articles', {
timeout: '10s', // fail fast instead of waiting 60s
});
4. "ERRO: context cancelled" during teardown
ERRO[0305] context cancelled
at teardown (file:///home/user/test.js:89:15(5))
This occurs when your teardown function takes too long. k6 has a graceful stop period (default 30 seconds) after which it forcefully terminates. If your teardown makes HTTP calls that time out, this error appears. Increase the graceful stop period:
export var options = {
scenarios: {
default: {
executor: 'ramping-vus',
gracefulStop: '60s', // give teardown more time
// ...
},
},
};
5. Memory issues with large response bodies
WARN[0180] Memory usage is high, consider reducing VU count or response body size
If your API returns large JSON payloads (pagination responses with thousands of records), k6 stores the entire response body in memory per VU. With 200 VUs each holding a 5MB response, you need 1GB just for response bodies. Solutions:
// Discard response body if you only need status codes
var res = http.get('http://localhost:3000/api/large-dataset', {
responseType: 'none', // do not store the body
});
// Or check and discard immediately
var res = http.get('http://localhost:3000/api/large-dataset');
check(res, { 'status 200': function (r) { return r.status === 200; } });
// The body will be garbage collected after this iteration
Best Practices
Test against a production-like environment, never production itself. Staging should mirror production's hardware, database size, connection pool config, and network topology. Testing against a dev box with SQLite tells you nothing about your PostgreSQL production database.
Always include think time with
sleep(). Real users do not fire requests in a tight loop. Withoutsleep(), you are testing raw throughput, which exaggerates your capacity by 10-50x. Usesleep(Math.random() * 3 + 1)for realistic 1-4 second delays between actions.Use the
constant-arrival-rateexecutor for write endpoints. Unlikeramping-vus, this executor maintains a fixed request rate regardless of response time. When your server slows down under load, the executor spawns more VUs to compensate -- exactly how real traffic behaves. This gives you a true picture of degradation.Set thresholds on p95 and p99, not averages. Averages hide tail latency. A p50 of 10ms with a p99 of 3 seconds means 1% of your users wait 300x longer than the median. Set thresholds on
p(95)andp(99)and fail the build when they are breached.Separate read and write scenarios. Read endpoints (GET) and write endpoints (POST/PUT/DELETE) have fundamentally different performance characteristics. Your GET
/articlesmight handle 5000 req/s while your POST/articlestops out at 200 req/s. Test them separately with different VU counts and thresholds.Run load tests in CI on every merge to main. Performance regressions creep in silently -- a new middleware, an unindexed query, a logging change. If you only run load tests quarterly, you will never pinpoint which commit caused the 3x latency increase. Run a quick smoke test (10 VUs, 30 seconds) on every PR and a full load test on every merge.
Tag your requests for per-endpoint analysis. Without tags, all HTTP metrics are aggregated together. A slow
/api/searchendpoint gets averaged with a fast/api/healthendpoint, hiding the problem. Usetags: { endpoint: 'search' }on every request and set per-endpoint thresholds.Version control your load test scripts alongside your application code. Load tests should live in a
tests/load/directory in your repo, reviewed in PRs just like application code. When someone changes an endpoint's behavior, they should update the corresponding load test.Warm up your application before measuring. The first 30 seconds of a Node.js application under load include JIT compilation, connection pool initialization, and cache warming. Start with a ramp-up stage and only set thresholds on the steady-state period, not the ramp-up.
Monitor your server during load tests, not just k6 output. k6 tells you client-side metrics (latency, errors). You also need server-side metrics: CPU usage, memory, event loop lag, database query times, connection pool utilization. Use
clinic,pm2 monit, or Grafana dashboards to correlate server behavior with k6 results.
