How to Load Test a REST API: Step-by-Step with k6
Hands-on tutorial for load testing REST APIs with k6 - from first script to Grafana dashboards, with authentication, data files, and thresholds.
Load testing a REST API with k6 is one of the most practical skills a backend engineer can have. This tutorial walks through the complete process from installing k6 to running a realistic multi-user scenario against a REST API, with authentication, data files, thresholds, and result export to Grafana.
Prerequisites
- k6 installed (macOS:
brew install k6, Linux: see k6.io/docs/get-started/installation) - A REST API to test (we use example.com patterns throughout)
- curl or httpie for manual endpoint exploration
Verify installation:
k6 version
# k6 v0.52.0 (go1.22.3, linux/amd64)
Your First k6 Script
Start simple. This script sends GET requests to an endpoint and checks the response:
// first-test.js
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 10, // 10 virtual users
duration: '30s', // Run for 30 seconds
};
export default function () {
const res = http.get('https://api.example.com/users');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
}
Run it:
k6 run first-test.js
k6 outputs a summary after the test completes:
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: first-test.js
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration
* default: 10 looping VUs for 30s (gracefulStop: 30s)
✓ status is 200
✓ response time < 500ms
checks.........................: 100.00% ✓ 2847 ✗ 0
data_received..................: 4.2 MB 140 kB/s
data_sent......................: 341 kB 11 kB/s
http_req_blocked...............: avg=1.2ms min=100µs med=500µs max=45ms
http_req_duration..............: avg=52ms min=18ms med=45ms max=312ms
{ expected_response:true }..: avg=52ms min=18ms med=45ms max=312ms
http_req_failed................: 0.00% ✓ 0 ✗ 2847
http_req_receiving.............: avg=3ms min=1ms med=2ms max=45ms
http_req_sending...............: avg=1ms min=500µs med=1ms max=8ms
http_req_waiting...............: avg=48ms min=15ms med=42ms max=300ms
http_reqs......................: 2847 94.89/s
iteration_duration.............: avg=105ms min=72ms med=95ms max=370ms
vus............................: 10 min=10 max=10
vus_max........................: 10 min=10 max=10
Reading the output: http_req_duration shows p50 (med), average (avg), and max values. The checks line shows how many checks passed and failed.
Realistic Traffic Simulation with Stages
Real traffic does not start at full load. Use stages to simulate ramp-up, sustained load, and ramp-down:
// staged-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 25 }, // Ramp up to 25 users over 2 minutes
{ duration: '5m', target: 25 }, // Hold at 25 users for 5 minutes
{ duration: '2m', target: 75 }, // Ramp up to 75 users
{ duration: '5m', target: 75 }, // Hold at 75 users
{ duration: '2m', target: 150 }, // Ramp up to 150 users
{ duration: '5m', target: 150 }, // Hold at 150 users
{ duration: '3m', target: 0 }, // Ramp down to 0
],
};
export default function () {
const res = http.get('https://api.example.com/products');
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1 + Math.random() * 2); // Think time: 1-3 seconds
}
The sleep() call is essential. Without think time, k6 fires requests as fast as possible, generating artificial load patterns. Real users pause between actions.
Authenticated Endpoints
Most APIs require authentication. Here is how to handle JWT bearer tokens:
// authenticated-test.js
import http from 'k6/http';
import { check, fail } from 'k6';
export const options = {
vus: 50,
duration: '5m',
};
const BASE_URL = __ENV.BASE_URL || 'https://api.example.com';
// Runs once per VU at startup - ideal for authentication
export function setup() {
// Note: setup() runs once total (not per VU) - use init code for per-VU auth
return {};
}
// Per-VU initialization - runs once per VU before the default function loop
// In k6, auth in the default function is the standard pattern for per-VU tokens
export default function () {
// Step 1: Authenticate
const loginPayload = JSON.stringify({
email: `testuser+${__VU}@example.com`, // Unique per VU
password: 'LoadTestPassword123!',
});
const loginRes = http.post(`${BASE_URL}/auth/token`, loginPayload, {
headers: { 'Content-Type': 'application/json' },
});
if (!check(loginRes, { 'login succeeded': (r) => r.status === 200 })) {
fail('Authentication failed - stopping VU');
}
const token = loginRes.json('access_token');
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
};
// Step 2: Use authenticated endpoint
const profileRes = http.get(`${BASE_URL}/users/me`, { headers });
check(profileRes, {
'profile status 200': (r) => r.status === 200,
'profile has id': (r) => r.json('id') !== undefined,
});
// Step 3: Refresh token if needed (simulate long sessions)
// For long-running tests, implement token refresh logic here
}
Performance tip: If all VUs authenticate before the test starts, the authentication endpoint gets a burst of requests at t=0. Stagger authentication by adding a sleep(__VU * 0.1) before the first auth call.
POST, PUT, DELETE with Data Files
Using realistic test data prevents caching from masking real performance characteristics. Store test data in JSON files:
Create test-data/products.json:
[
{ "name": "Widget Pro", "price": 29.99, "sku": "WGT-001", "category": "tools" },
{ "name": "Gadget Plus", "price": 49.99, "sku": "GDG-002", "category": "electronics" },
{ "name": "Doohickey Basic", "price": 9.99, "sku": "DHK-003", "category": "accessories" }
]
Load and use in your test script:
// data-driven-test.js
import http from 'k6/http';
import { check } from 'k6';
import { SharedArray } from 'k6/data';
// SharedArray loads data once and shares across all VUs (memory efficient)
const products = new SharedArray('products', function () {
return JSON.parse(open('./test-data/products.json'));
});
const users = new SharedArray('users', function () {
return JSON.parse(open('./test-data/users.json'));
});
export const options = {
vus: 50,
duration: '10m',
};
const BASE_URL = __ENV.BASE_URL || 'https://api.example.com';
export default function () {
// Pick a random user for this iteration
const user = users[__VU % users.length];
// Authenticate
const loginRes = http.post(`${BASE_URL}/auth/token`,
JSON.stringify({ email: user.email, password: user.password }),
{ headers: { 'Content-Type': 'application/json' } }
);
const token = loginRes.json('access_token');
const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
// CREATE - POST new product
const product = products[Math.floor(Math.random() * products.length)];
const createRes = http.post(
`${BASE_URL}/products`,
JSON.stringify({
...product,
sku: `${product.sku}-${Date.now()}`, // Unique SKU per request
}),
{ headers }
);
const created = check(createRes, {
'create product status 201': (r) => r.status === 201,
'create returns product id': (r) => r.json('id') !== undefined,
});
if (!created) return;
const productId = createRes.json('id');
// READ - GET the created product
const getRes = http.get(`${BASE_URL}/products/${productId}`, { headers });
check(getRes, { 'get product status 200': (r) => r.status === 200 });
// UPDATE - PUT updated product
const updateRes = http.put(
`${BASE_URL}/products/${productId}`,
JSON.stringify({ ...product, price: product.price * 1.1 }),
{ headers }
);
check(updateRes, { 'update product status 200': (r) => r.status === 200 });
// DELETE - delete the test product
const deleteRes = http.del(`${BASE_URL}/products/${productId}`, null, { headers });
check(deleteRes, { 'delete product status 204': (r) => r.status === 204 });
}
Thresholds as Quality Gates
Thresholds define pass/fail criteria for your test. k6 exits with a non-zero code if any threshold is violated, making it straightforward to fail CI/CD pipelines:
export const options = {
stages: [
{ duration: '2m', target: 100 },
{ duration: '5m', target: 100 },
{ duration: '2m', target: 0 },
],
thresholds: {
// Overall request duration
'http_req_duration': [
'p(95)<500', // 95% of requests complete in < 500ms
'p(99)<2000', // 99% of requests complete in < 2000ms
],
// Error rate
'http_req_failed': ['rate<0.01'], // Less than 1% errors
// Specific endpoints (using tags)
'http_req_duration{name:login}': ['p(95)<1000'],
'http_req_duration{name:checkout}': ['p(95)<3000'],
// Custom business metrics
'checkout_success_rate': ['rate>0.995'],
// Checks pass rate
'checks': ['rate>0.99'],
},
};
Tag specific requests for per-endpoint thresholds:
const loginRes = http.post(
`${BASE_URL}/auth/token`,
JSON.stringify({ email, password }),
{
headers: { 'Content-Type': 'application/json' },
tags: { name: 'login' }, // This request appears as 'login' in thresholds
}
);
Reading Results
After a test run, k6 prints a results summary. Key metrics to review:
http_req_duration..............: avg=95ms min=18ms med=76ms p(90)=165ms p(95)=225ms p(99)=612ms max=3241ms
What to look for:
- Large gap between p95 and p99: indicates outlier requests (slow queries, cache misses, timeouts)
- Large gap between p50 and p95: indicates that load is causing more than average degradation
- Max significantly higher than p99: isolated extreme outliers (timeouts, retries)
Check which checks failed:
✓ status is 200 (2980/3000)
✗ response time < 500ms (2820/3000) - 6% failures
This tells you 6% of requests exceeded your 500ms threshold - a significant finding that warrants investigation.
Exporting Results to Grafana
For better result visualization, stream k6 metrics to a Grafana stack in real time.
Set up InfluxDB and Grafana locally with Docker:
docker run -d \
--name influxdb \
-p 8086:8086 \
-e INFLUXDB_DB=k6 \
influxdb:1.8
docker run -d \
--name grafana \
-p 3000:3000 \
--link influxdb:influxdb \
grafana/grafana
Run k6 with InfluxDB output:
k6 run \
--out influxdb=http://localhost:8086/k6 \
api-load-test.js
Import the official k6 Grafana dashboard (ID: 2587) in Grafana. It provides real-time visualization of VUs, request rate, response time percentiles, and error rate during the test.
Alternatively, use k6 Cloud (free tier available) for hosted result storage and comparison between test runs:
k6 cloud api-load-test.js
Avoiding Common Mistakes
Do not forget sleep(). Scripts without think time generate artificial load. Real users do not send 100 requests per second from a single session.
Use unique data per VU. Using the same user ID or product ID across all VUs means database queries hit the same cache entries repeatedly. Use __VU and __ITER to vary data: email: testuser+${__VU}@example.com.
Set timeouts explicitly. k6’s default timeout is 60 seconds. For APIs that should respond in under 2 seconds, set a stricter timeout to catch hangs quickly:
const res = http.get(url, { timeout: '10s' });
Test your test environment first. Run with 1 VU before scaling up. Verify you are testing what you think you are testing (correct endpoints, correct auth, correct data).
Parse and use response data. If the API returns pagination cursors, use them. If it returns IDs needed for subsequent requests, extract and use them. Realistic request sequencing matters.
Our API load testing service builds production-grade test suites for your specific API, including data setup, scenario design, and CI/CD integration - delivered within one sprint.
Know Your Scaling Ceiling
Book a free 30-minute capacity scope call with our load testing engineers. We review your architecture, traffic expectations, and upcoming scaling events — and scope the load test that will give you the data you need.
Talk to an Expert