API Load Testing: Performance and Stress Testing with k6
In this tutorial, you will learn about API Load Testing: Performance and Stress Testing with k6. We cover key concepts, practical examples, and best practices to help you master this topic.
Load testing validates API performance under expected and peak traffic using simulated virtual users, measuring response times, throughput, error rates, and identifying bottlenecks before they impact real users.
What You'll Learn
How to load test APIs with k6, write test scripts with virtual users and ramp-up stages, set performance thresholds, use custom metrics and checks, run browser-based load tests, integrate with CI/CD, and analyze results to identify bottlenecks.
Why It Matters
Performance issues cause user abandonment and revenue loss. A 1-second delay reduces conversions by 7%. DodaTech load tests every API endpoint to ensure 95th percentile response times under 500ms for 10x peak traffic.
Real-World Use
DodaTech runs a load test simulating 1,000 concurrent users browsing products, adding to cart, and checking out. k6 reports 95th percentile checkout time of 1.2s โ above the 500ms threshold. The team identifies the payment API as the bottleneck and optimizes it.
flowchart LR
A["k6 Script\nVirtual Users"] --> B["Ramp-Up\nStage"]
B --> C["Steady State\nLoad"]
C --> D["Ramp-Down\nStage"]
D --> E["Results\nCollection"]
E --> F{"Thresholds\nMet?"}
F -->|Yes| G["Performance\nPasses"]
F -->|No| H["Bottleneck\nIdentified"]
H --> I["Optimize &\nRe-test"]
style A fill:#dbeafe,stroke:#2563eb
style E fill:#bbf7d0,stroke:#16a34a
style H fill:#fecaca,stroke:#dc2626
Basic k6 Test Script
// k6 test script for API load testing
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 10, // 10 virtual users
duration: '30s', // Run for 30 seconds
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
http_req_failed: ['rate<0.01'], // Less than 1% failure rate
},
};
export default function () {
const BASE_URL = 'https://api.staging.dodatech.com/v1';
// 1. Get users list
const usersResp = http.get(`${BASE_URL}/users`, {
headers: { 'Accept': 'application/json' },
});
check(usersResp, {
'users status 200': (r) => r.status === 200,
'users response < 500ms': (r) => r.timings.duration < 500,
});
sleep(1); // Think time between actions
// 2. Create a new user
const payload = JSON.stringify({
email: `loadtest-${__ITER}@example.com`,
name: `Load Test User ${__ITER}`,
});
const createResp = http.post(`${BASE_URL}/users`, payload, {
headers: { 'Content-Type': 'application/json' },
});
check(createResp, {
'create status 201': (r) => r.status === 201,
'create response < 1000ms': (r) => r.timings.duration < 1000,
});
sleep(1);
}
Ramp-Up Patterns
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Custom metrics
const errorRate = new Rate('errors');
const checkoutLatency = new Trend('checkout_duration');
export const options = {
stages: [
{ duration: '2m', target: 100 }, // Ramp up to 100 users
{ duration: '5m', target: 100 }, // Stay at 100 users
{ duration: '2m', target: 200 }, // Ramp up to 200 users
{ duration: '5m', target: 200 }, // Stay at 200 users
{ duration: '2m', target: 0 }, // Ramp down to 0
],
thresholds: {
http_req_duration: ['p(95)<1000', 'p(99)<2000'],
errors: ['rate<0.05'], // Less than 5% errors
checkout_duration: ['p(95)<2000'], // Checkout-specific threshold
},
};
export default function () {
const responses = http.batch([
['GET', 'https://api.dodatech.com/v1/products', null, { tags: { name: 'products' } }],
['GET', 'https://api.dodatech.com/v1/categories', null, { tags: { name: 'categories' } }],
]);
responses.forEach((res) => {
check(res, { 'status is 200': (r) => r.status === 200 });
if (res.status !== 200) {
errorRate.add(1);
}
});
const checkoutStart = Date.now();
const checkoutResp = http.post('https://api.dodatech.com/v1/checkout', '{}');
checkoutLatency.add(Date.now() - checkoutStart);
check(checkoutResp, {
'checkout status 200': (r) => r.status === 200,
});
sleep(Math.random() * 3 + 1); // Random think time
}
Advanced k6 Features
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { SharedArray } from 'k6/data';
import { randomItem } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
// Load test data from JSON file
const users = new SharedArray('users', function () {
return JSON.parse(open('./test-data/users.json'));
});
export const options = {
scenarios: {
// Scenario 1: Normal traffic pattern
normal_traffic: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '5m', target: 50 },
{ duration: '10m', target: 50 },
{ duration: '5m', target: 0 },
],
gracefulRampDown: '30s',
},
// Scenario 2: Spike test
spike_test: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 0 },
{ duration: '30s', target: 500 }, // Sudden spike
{ duration: '1m', target: 500 },
{ duration: '30s', target: 0 },
],
startTime: '20m', // Start after normal traffic
},
},
thresholds: {
http_req_duration: ['p(95)<500', 'avg<200'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
group('User Browse Flow', function () {
const user = randomItem(users);
const products = http.get('https://api.dodatech.com/v1/products', {
tags: { name: 'browse_products', type: 'browse' },
});
check(products, { 'browse products ok': (r) => r.status === 200 });
sleep(1);
const productId = products.json()[0]?.id;
if (productId) {
const detail = http.get(`https://api.dodatech.com/v1/products/${productId}`);
check(detail, { 'product detail ok': (r) => r.status === 200 });
}
});
}
Running and Analyzing Results
# Install k6
# macOS: brew install k6
# Linux: https://k6.io/docs/getting-started/installation
# Run basic test
k6 run load-test.js
# Run with output to JSON for analysis
k6 run --out json=results.json load-test.js
# Run with HTML report
k6 run --out html=report.html load-test.js
# Run with thresholds summary only
k6 run --quiet load-test.js
# Expected output (partial):
# โ users status 200
# โ users response < 500ms
# โ create status 201
#
# checks.........................: 100.00% โ 1200 โ 0
# data_received..................: 2.4 MB 78 kB/s
# data_sent......................: 240 kB 7.9 kB/s
# http_req_blocked...............: avg=1.2ms p(95)=4.5ms
# http_req_connecting............: avg=800ยตs p(95)=3.2ms
# http_req_duration..............: avg=245ms p(95)=420ms โ
# http_req_failed................: 0.00% โ 0 โ 600
# vus............................: 10 min=10 max=10
# vus_max........................: 10 min=10 max=10
Common Mistakes
1. Not Using Ramp-Up
Starting with full load immediately can overwhelm the system and cause false failures. Ramp up gradually (2-5 minutes) to let auto-scaling and connection pools warm up.
2. Ignoring Think Time
Without sleep/think time between requests, tests don't simulate real user behavior. A user waits between browsing products, filling forms, and clicking buttons. Add realistic delays (1-5 seconds).
3. Testing Only the Happy Path
Real users encounter errors, network issues, and invalid inputs. Include error scenarios in load tests to verify the system degrades gracefully under stress.
4. Not Setting Thresholds
Without thresholds, you have to manually review results to decide if the test passed. Set thresholds for p(95) latency, error rate, and throughput. Fail the test automatically on threshold violation.
5. Running Load Tests Against Production
Load testing production risks degrading real user experience. Always test against a staging environment that mirrors production infrastructure. If production testing is necessary, run at low traffic times with monitoring alerts.
Practice Questions
- What is the difference between load testing and stress testing?
- Why is ramp-up important in load tests?
- What are k6 thresholds and how do you use them?
- How do you simulate realistic user behavior in load tests?
Answers:
- Load testing simulates expected traffic (100 users, 500 req/s). Stress testing pushes beyond expected limits (500 users, 2000 req/s) to find breaking points. Spike testing is sudden traffic surges.
- Ramp-up gradually increases virtual users, allowing auto-scaling, Connection Pool warming, and cache priming. Without ramp-up, initial requests experience cold-start latency that skews results.
- Thresholds are pass/fail criteria:
p(95)<500means 95% of requests must complete in under 500ms. Thresholds make load tests self-validating in CI/CD pipelines. - Use think time (
sleep()), random delays (Math.random() * 3 + 1), realistic data from files (SharedArray), varied user flows (browse, search, purchase), and user agents mimicking real browsers.
Challenge: Write a comprehensive k6 load test suite: 3 scenarios (normal traffic, spike, stress), realistic user flows (browse -> product detail -> cart -> checkout), thresholds for p(95) latency and error rate, custom metrics for checkout flow, test data from external JSON file, and CI/CD integration with HTML report generation.
FAQ
Mini Project
Build a complete load testing suite for an e-commerce API: k6 scripts for 3 scenarios (browse, search, checkout), ramp-up stages, realistic think times, thresholds for all endpoints, custom metrics for critical flows, test data from JSON files, HTML report generation, and CI/CD integration with failure on threshold breach.
What's Next
Test Data Management โ manage test data with fixtures and factories.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro