Load Testing Node.js Services
Load testing ensures your application can handle expected traffic and helps identify performance bottlenecks before they affect users. This guide covers practical load testing strategies using k6, a modern load testing tool designed for developers.
Why Load Testing Matters
Real-World Disaster Scenarios
Scenario 1: The Product Launch
Company launches new product feature
Marketing sends email to 100K users
All click the link simultaneously at 9 AM
Website crashes within 2 minutes
Sales team loses $500K in potential revenue
Engineering team spends weekend firefighting
Scenario 2: The Slow Degradation
API works fine with 100 users
At 1,000 users, response time increases to 2 seconds
At 5,000 users, response time hits 10 seconds
At 10,000 users, database connections exhaust, everything fails
Nobody noticed because growth was gradual
Scenario 3: The Black Friday
E-commerce site handles 10K users normally
Black Friday brings 100K concurrent users
Checkout page times out
Customers can't complete purchases
Competitors get the business
Load testing prevents these disasters by:
- Identifying breaking points before production
- Finding bottlenecks (database, API, memory leaks)
- Validating infrastructure capacity
- Establishing performance baselines
- Building confidence in your system
What is k6?
k6 is a modern, developer-friendly load testing tool that:
- Uses JavaScript for test scripts (familiar for Node.js developers)
- Runs from command line (easy to integrate into CI/CD)
- Provides detailed metrics and insights
- Supports various testing scenarios (load, stress, spike tests)
- Can test HTTP, WebSockets, gRPC
Why k6 over alternatives?
- vs JMeter: Lighter weight, no GUI needed, better for CI/CD
- vs Artillery: More detailed metrics, better for complex scenarios
- vs Apache Bench: Much more powerful, supports complex workflows
- vs Locust: No Python needed, simpler for JavaScript developers
Installing k6
macOS:
brew install k6
Linux:
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
Windows:
choco install k6
Verify installation:
k6 version
Your First Load Test
Simple API Test
test.js:
import http from 'k6/http';
import { sleep, check } from 'k6';
// Test configuration
export const options = {
vus: 10, // 10 virtual users
duration: '30s', // Run for 30 seconds
};
// Test scenario
export default function () {
// Make HTTP request
const response = http.get('http://localhost:3000/api/users');
// Verify response
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
// Wait 1 second between requests
sleep(1);
}
Run the test:
k6 run test.js
What this does:
- Simulates 10 users making requests simultaneously
- Each user makes a request, waits 1 second, repeats for 30 seconds
- Checks that responses are successful (200) and fast (< 500ms)
- Reports detailed metrics at the end
Understanding the Output
Sample k6 output:
✓ status is 200
✓ response time < 500ms
checks.........................: 100.00% ✓ 300 ✗ 0
data_received..................: 1.5 MB 50 kB/s
data_sent......................: 30 kB 1.0 kB/s
http_req_blocked...............: avg=1.2ms min=500µs max=15ms
http_req_connecting............: avg=800µs min=300µs max=10ms
http_req_duration..............: avg=250ms min=100ms max=450ms
{ expected_response:true }...: avg=250ms min=100ms max=450ms
http_req_failed................: 0.00% ✓ 0 ✗ 300
http_req_receiving.............: avg=500µs min=100µs max=2ms
http_req_sending...............: avg=300µs min=100µs max=1ms
http_req_tls_handshaking.......: avg=0s min=0s max=0s
http_req_waiting...............: avg=249ms min=99ms max=448ms
http_reqs......................: 300 10/s
iteration_duration.............: avg=1.25s min=1.1s max=1.5s
iterations.....................: 300 10/s
vus............................: 10 min=10 max=10
vus_max........................: 10 min=10 max=10
Key metrics explained:
checks: Did your assertions pass?
100%means all checks passed- If
< 100%, some requests failed your criteria
http_req_duration: How long requests took
avg=250ms: Average response time (most important metric)min=100ms: Fastest requestmax=450ms: Slowest request- Target: Keep avg < 500ms for good user experience
http_req_failed: Request failure rate
0.00%: No failed requests (good!)- If
> 0%, investigate errors
http_reqs: Total requests made
300requests in 30 seconds = 10 requests/second
What to look for:
- ✓ All checks passing (green checkmarks)
- ✓ Low http_req_duration (< 500ms average)
- ✓ Zero http_req_failed
- ✓ Consistent min/max (no huge outliers)
Different Types of Load Tests
1. Smoke Test (Sanity Check)
Purpose: Verify system works under minimal load
When to use: Before running heavier tests, in CI/CD pipeline
smoke-test.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 1, // Just 1 user
duration: '1m', // For 1 minute
};
export default function () {
const response = http.get('http://localhost:3000/api/health');
check(response, {
'status is 200': (r) => r.status === 200,
'no errors': (r) => !r.body.includes('error'),
});
sleep(1);
}
Run it:
k6 run smoke-test.js
What you're checking:
- API endpoints respond correctly
- No obvious errors
- Baseline performance metrics
Real-world usage:
# In CI/CD pipeline
npm run build
npm start &
sleep 5 # Wait for server to start
k6 run smoke-test.js
if [ $? -eq 0 ]; then
echo "Smoke test passed, proceeding with deployment"
else
echo "Smoke test failed, aborting deployment"
exit 1
fi
2. Load Test (Expected Traffic)
Purpose: Test system under normal expected load
When to use: Regular testing, before major releases
load-test.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 50 }, // Ramp up to 50 users over 2 min
{ duration: '5m', target: 50 }, // Stay at 50 users for 5 min
{ duration: '2m', target: 0 }, // Ramp down to 0 users
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests must complete in 500ms
http_req_failed: ['rate<0.01'], // Error rate must be below 1%
},
};
export default function () {
const response = http.get('http://localhost:3000/api/products');
check(response, {
'status is 200': (r) => r.status === 200,
'has products': (r) => JSON.parse(r.body).products.length > 0,
});
sleep(1);
}
What this simulates:
Users over time:
0 -----> 50 (ramp up: 2 min)
50 -----> 50 (sustained: 5 min) ← This is your normal traffic
50 -----> 0 (ramp down: 2 min)
Interpreting results:
Success:
✓ http_req_duration............: avg=250ms p(95)=450ms ← 95% under 500ms
✓ http_req_failed..............: 0.00% ← No errors
System handles expected load well!
Failure:
✗ http_req_duration............: avg=800ms p(95)=1200ms ← Too slow!
✗ http_req_failed..............: 2.5% ← Errors occurring
System struggles under normal load. Investigate before production!
3. Stress Test (Breaking Point)
Purpose: Find the maximum capacity of your system
When to use: Capacity planning, understanding limits
stress-test.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 100 }, // Ramp to 100 users
{ duration: '5m', target: 100 }, // Stay at 100
{ duration: '2m', target: 200 }, // Ramp to 200
{ duration: '5m', target: 200 }, // Stay at 200
{ duration: '2m', target: 300 }, // Ramp to 300
{ duration: '5m', target: 300 }, // Stay at 300
{ duration: '2m', target: 0 }, // Ramp down
],
};
export default function () {
const response = http.get('http://localhost:3000/api/products');
check(response, {
'status is 200': (r) => r.status === 200,
});
sleep(1);
}
What you're looking for:
Watching the metrics:
At 100 users: avg=250ms, 0% errors ✓ Healthy
At 200 users: avg=500ms, 0% errors ⚠️ Slowing down
At 300 users: avg=2000ms, 5% errors ✗ System breaking
Breaking point identified: Between 200-300 concurrent users
Real-world implications:
Current traffic: 50 concurrent users
Growth rate: 20% per quarter
Breaking point: 250 concurrent users
Timeline:
Q1: 50 users ✓ Safe
Q2: 60 users ✓ Safe
Q3: 72 users ✓ Safe
Q4: 86 users ✓ Safe
Q5: 103 users ✓ Safe
Q6: 124 users ✓ Safe
Q7: 149 users ✓ Safe
Q8: 179 users ✓ Safe
Q9: 215 users ⚠️ Getting close
Q10: 258 users ✗ Will exceed capacity
Action needed: Scale infrastructure before Q9 (18 months from now)
4. Spike Test (Sudden Traffic)
Purpose: Test system recovery from sudden traffic spikes
When to use: Before marketing campaigns, product launches
spike-test.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '10s', target: 10 }, // Normal traffic
{ duration: '1m', target: 200 }, // Sudden spike!
{ duration: '10s', target: 10 }, // Back to normal
],
};
export default function () {
const response = http.get('http://localhost:3000/api/products');
check(response, {
'status is 200': (r) => r.status === 200,
'response time OK': (r) => r.timings.duration < 2000,
});
sleep(1);
}
What this simulates:
Marketing sends email to 50K users at 9 AM
All click the link within 1 minute
Traffic spikes from 10 to 200 concurrent users instantly
Good system behavior:
Before spike: avg=200ms, 0% errors
During spike: avg=800ms, 2% errors ← Degraded but functional
After spike: avg=200ms, 0% errors ← Recovered quickly
Bad system behavior:
Before spike: avg=200ms, 0% errors
During spike: avg=5000ms, 50% errors ← Completely broken
After spike: avg=3000ms, 20% errors ← Didn't recover!
5. Soak Test (Endurance Test)
Purpose: Find memory leaks and degradation over time
When to use: Before major releases, after performance fixes
soak-test.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '5m', target: 50 }, // Ramp up
{ duration: '3h', target: 50 }, // Run for 3 hours!
{ duration: '5m', target: 0 }, // Ramp down
],
};
export default function () {
const response = http.get('http://localhost:3000/api/products');
check(response, {
'status is 200': (r) => r.status === 200,
'response time OK': (r) => r.timings.duration < 500,
});
sleep(1);
}
What you're looking for:
Healthy system:
After 30 min: avg=250ms, 0% errors
After 1 hour: avg=250ms, 0% errors
After 2 hours: avg=250ms, 0% errors
After 3 hours: avg=250ms, 0% errors ✓ Stable!
Memory leak:
After 30 min: avg=250ms, 0% errors
After 1 hour: avg=400ms, 0% errors ⚠️ Slowing down
After 2 hours: avg=1000ms, 2% errors ⚠️ Getting worse
After 3 hours: avg=5000ms, 20% errors ✗ Memory leak!
Monitor during soak test:
# Monitor Node.js memory usage
watch -n 5 'curl http://localhost:3000/metrics | grep process_resident_memory_bytes'
# If memory keeps growing, you have a leak!
Real-World Testing Scenarios
Testing a REST API with Authentication
api-with-auth-test.js:
import http from 'k6/http';
import { check, group, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 20 },
{ duration: '3m', target: 20 },
{ duration: '1m', target: 0 },
],
};
export default function () {
const BASE_URL = 'http://localhost:3000';
// Step 1: Login
let loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
email: 'test@example.com',
password: 'password123',
}), {
headers: { 'Content-Type': 'application/json' },
});
check(loginRes, {
'login successful': (r) => r.status === 200,
'got token': (r) => JSON.parse(r.body).token !== undefined,
});
const token = JSON.parse(loginRes.body).token;
// Step 2: Get user profile
let profileRes = http.get(`${BASE_URL}/api/user/profile`, {
headers: { 'Authorization': `Bearer ${token}` },
});
check(profileRes, {
'profile retrieved': (r) => r.status === 200,
});
// Step 3: Update profile
let updateRes = http.put(`${BASE_URL}/api/user/profile`, JSON.stringify({
name: 'Updated Name',
}), {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
check(updateRes, {
'profile updated': (r) => r.status === 200,
});
sleep(1);
}
What this tests:
- Complete user workflow (login → read → write)
- Authentication token handling
- Multiple API endpoints in sequence
Testing a Shopping Cart Flow
ecommerce-flow-test.js:
import http from 'k6/http';
import { check, group, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 50 },
{ duration: '5m', target: 50 },
{ duration: '2m', target: 0 },
],
thresholds: {
'http_req_duration{page:products}': ['p(95)<500'],
'http_req_duration{page:cart}': ['p(95)<300'],
'http_req_duration{page:checkout}': ['p(95)<1000'],
},
};
export default function () {
const BASE_URL = 'http://localhost:3000';
group('Browse Products', function () {
let res = http.get(`${BASE_URL}/api/products`, {
tags: { page: 'products' },
});
check(res, { 'products loaded': (r) => r.status === 200 });
sleep(2); // User spends 2 seconds browsing
});
group('Add to Cart', function () {
let res = http.post(`${BASE_URL}/api/cart`, JSON.stringify({
productId: 'prod-123',
quantity: 2,
}), {
headers: { 'Content-Type': 'application/json' },
tags: { page: 'cart' },
});
check(res, { 'added to cart': (r) => r.status === 200 });
sleep(1);
});
group('View Cart', function () {
let res = http.get(`${BASE_URL}/api/cart`, {
tags: { page: 'cart' },
});
check(res, { 'cart loaded': (r) => r.status === 200 });
sleep(3); // User reviews cart
});
group('Checkout', function () {
let res = http.post(`${BASE_URL}/api/checkout`, JSON.stringify({
paymentMethod: 'credit_card',
shippingAddress: '123 Main St',
}), {
headers: { 'Content-Type': 'application/json' },
tags: { page: 'checkout' },
});
check(res, { 'checkout successful': (r) => r.status === 200 });
});
sleep(10); // Delay before next iteration
}
Why use groups and tags:
- Groups organize related requests
- Tags let you set different thresholds per page
- Reports show performance per user flow step
Output will show:
✓ http_req_duration{page:products}......: avg=350ms p(95)=450ms
✓ http_req_duration{page:cart}..........: avg=200ms p(95)=280ms
✓ http_req_duration{page:checkout}......: avg=800ms p(95)=950ms
Now you know checkout is slower (expected, more processing) but still within threshold.
Advanced Patterns
Using Test Data
test-with-data.js:
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';
// Load test data (only loaded once, shared across VUs)
const users = new SharedArray('users', function () {
return [
{ email: 'user1@example.com', password: 'pass1' },
{ email: 'user2@example.com', password: 'pass2' },
{ email: 'user3@example.com', password: 'pass3' },
// ... more users
];
});
export const options = {
vus: 10,
duration: '30s',
};
export default function () {
// Each VU picks a different user
const user = users[__VU % users.length];
const res = http.post('http://localhost:3000/api/login', JSON.stringify(user), {
headers: { 'Content-Type': 'application/json' },
});
check(res, {
'login successful': (r) => r.status === 200,
});
}
Loading data from CSV:
import { SharedArray } from 'k6/data';
import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';
const csvData = new SharedArray('users', function () {
return papaparse.parse(open('./users.csv'), { header: true }).data;
});
export default function () {
const user = csvData[__VU % csvData.length];
// Use user.email, user.password, etc.
}
Custom Metrics
custom-metrics.js:
import http from 'k6/http';
import { Trend, Counter } from 'k6/metrics';
// Custom metrics
const checkoutDuration = new Trend('checkout_duration');
const checkoutErrors = new Counter('checkout_errors');
export const options = {
vus: 10,
duration: '1m',
};
export default function () {
const startTime = Date.now();
const res = http.post('http://localhost:3000/api/checkout', /* ... */);
const duration = Date.now() - startTime;
checkoutDuration.add(duration);
if (res.status !== 200) {
checkoutErrors.add(1);
}
}
Output will include:
checkout_duration..............: avg=850ms min=500ms max=2000ms p(95)=1200ms
checkout_errors................: 5
Testing WebSocket Connections
websocket-test.js:
import ws from 'k6/ws';
import { check } from 'k6';
export const options = {
vus: 50,
duration: '1m',
};
export default function () {
const url = 'ws://localhost:3000/ws';
const res = ws.connect(url, function (socket) {
socket.on('open', function () {
console.log('connected');
socket.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }));
});
socket.on('message', function (data) {
console.log('received:', data);
});
socket.on('close', function () {
console.log('disconnected');
});
socket.setTimeout(function () {
socket.close();
}, 10000); // Close after 10 seconds
});
check(res, { 'status is 101': (r) => r && r.status === 101 });
}
Interpreting Results and Taking Action
Reading the Metrics
Key metrics to watch:
1. http_req_duration (Response Time)
avg=250ms ← Average (most important)
min=100ms ← Best case
max=2000ms ← Worst case (check for outliers!)
p(90)=400ms ← 90th percentile
p(95)=500ms ← 95th percentile (commonly used SLA)
p(99)=800ms ← 99th percentile (catches outliers)
What to look for:
- Keep avg < 500ms for good user experience
- p(95) < 1000ms is acceptable for most apps
- Large gap between avg and p(95) suggests inconsistency
2. http_req_failed (Error Rate)
0.00% ← Perfect!
0.5% ← Acceptable for some apps
5% ← Too high, investigate
3. http_reqs (Throughput)
http_reqs: 1200 40/s
Your system handled 40 requests per second.
4. iterations (Completed Workflows)
iterations: 300
300 users completed the full test scenario.
Common Problems and Solutions
Problem 1: Response Time Degradation
Symptoms:
At 10 users: avg=200ms
At 50 users: avg=500ms
At 100 users: avg=2000ms
Possible causes:
- Database not scaling: Add indexes, connection pooling, read replicas
- No caching: Add Redis/Memcached
- Synchronous operations: Use async/await properly
- CPU-bound operations: Offload to background workers
Investigation:
# Check database slow queries
# Check Node.js event loop delay
# Profile with Chrome DevTools or clinic.js
Problem 2: Memory Leak
Symptoms:
Start: avg=200ms, memory=100MB
1 hour: avg=300ms, memory=500MB
2 hours: avg=600ms, memory=1.2GB
3 hours: CRASH (out of memory)
Possible causes:
- Global arrays/objects growing: Clear old data
- Event listeners not removed: Use
removeListener - Closures holding references: Check circular references
- Caching without TTL: Add expiration to cached data
Investigation:
# Take heap snapshots at intervals
node --inspect index.js
# Use Chrome DevTools Memory profiler
Problem 3: Database Connection Exhaustion
Symptoms:
At 10 users: 0% errors
At 50 users: 0% errors
At 100 users: 30% errors: "too many connections"
Solution:
// Bad: No connection pooling
const db = mysql.createConnection(config);
// Good: Connection pool
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'password',
database: 'mydb',
connectionLimit: 100, // Max connections
queueLimit: 0, // Unlimited queue
});
Problem 4: API Rate Limiting
Symptoms:
At 50 users: 0% errors
At 100 users: 20% errors: "429 Too Many Requests"
This is actually good! Rate limiting is protecting your system.
Options:
- Increase rate limits (if your system can handle it)
- Implement backoff and retry in clients
- Scale infrastructure to handle more requests
Integration with CI/CD
GitHub Actions Example
.github/workflows/load-test.yml:
name: Load Test
on:
pull_request:
branches: [main]
schedule:
- cron: '0 2 * * *' # Run daily at 2 AM
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- 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-get update
sudo apt-get install k6
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Start server
run: |
npm start &
sleep 10 # Wait for server to start
- name: Run smoke test
run: k6 run tests/smoke-test.js
- name: Run load test
run: k6 run tests/load-test.js
- name: Check thresholds
run: |
if [ $? -ne 0 ]; then
echo "Load test failed! Check metrics."
exit 1
fi
Fail Build on Performance Regression
load-test-with-thresholds.js:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 50 },
{ duration: '3m', target: 50 },
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% under 500ms
http_req_failed: ['rate<0.01'], // <1% errors
'http_req_duration{page:checkout}': ['p(95)<1000'], // Checkout can be slower
},
};
export default function () {
const res = http.get('http://localhost:3000/api/products');
check(res, { 'status is 200': (r) => r.status === 200 });
}
If thresholds fail, k6 exits with non-zero code, failing your CI/CD pipeline.
Best Practices
1. Start Small
Week 1: Smoke test (1 user)
Week 2: Small load test (10 users)
Week 3: Normal load test (50 users)
Week 4: Stress test (find breaking point)
Don't jump straight to stress testing—build up gradually.
2. Test in Staging First
Never run load tests against production without explicit approval.
3. Realistic User Behavior
// Bad: Unrealistic
export default function () {
http.get('/api/products');
// Immediately repeat, no think time
}
// Good: Realistic
export default function () {
http.get('/api/products');
sleep(Math.random() * 3 + 2); // 2-5 seconds think time
http.get('/api/product/123');
sleep(Math.random() * 2 + 1); // 1-3 seconds
http.post('/api/cart', /* ... */);
sleep(Math.random() * 5 + 3); // 3-8 seconds before checkout
}
4. Monitor Your System During Tests
Don't just watch k6 output—monitor your system:
# CPU and memory
htop
# Database connections
watch -n 1 'mysql -e "SHOW PROCESSLIST"'
# Node.js metrics
curl http://localhost:3000/metrics
# Application logs
tail -f /var/log/app.log
5. Run Tests Regularly
- Smoke test: Every deployment
- Load test: Weekly or before major releases
- Stress test: Monthly or before traffic spikes
- Soak test: Before major releases
6. Document Baselines
Current Performance Baseline (as of 2024-01-15):
- 50 concurrent users
- Avg response time: 250ms
- p(95) response time: 450ms
- 0% error rate
- Throughput: 40 req/sec
- Infrastructure: 2x 4-core servers, 8GB RAM, PostgreSQL 14
Next review: 2024-04-15
Track changes over time to detect regressions.
Summary
Load testing with k6 helps you:
- Prevent disasters: Find problems before users do
- Understand capacity: Know your limits and plan growth
- Validate performance: Ensure SLAs are met
- Build confidence: Deploy with knowledge your system can handle traffic
Getting started checklist:
- ✓ Install k6
- ✓ Write a simple smoke test
- ✓ Run it against your API
- ✓ Gradually increase load
- ✓ Find your breaking point
- ✓ Fix bottlenecks
- ✓ Integrate into CI/CD
- ✓ Run regularly
Remember: The best time to find performance problems is before your users do!