A load test that hammers a single endpoint with uniform traffic answers a narrow question. Real production traffic is a mix: 70% users browsing, 20% searching, 10% checking out. This lesson covers how to model realistic workloads in K6 — proportional traffic distribution, realistic think times, and multi-step user flows grouped for clean metric separation.
The anatomy of a load test
Step 1 of 4
Ramp-up
VU count grows from 0 to the target over 2–5 minutes. Caches warm, connection pools fill, and the system reaches a representative steady state. Results during ramp-up are excluded from SLA evaluation in most teams.
The standard load test stage pattern
export const options = {
stages: [
{ duration: '5m', target: 100 }, // ramp up to expected peak
{ duration: '20m', target: 100 }, // hold at peak — measurement window
{ duration: '5m', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.01'],
},
};The 20-minute hold is the core of the test. It is long enough to reveal connection pool exhaustion (usually visible within 5–10 minutes) but short enough for a pre-deploy pipeline run.
Modelling proportional traffic
Real users split across endpoints. Use Math.random() to weight traffic proportionally without complex frameworks:
import http from 'k6/http';
import { check, sleep } from 'k6';
export default function (data) {
const roll = Math.random();
if (roll < 0.70) {
// 70% of VUs browse the product catalogue
browseCatalogue(data);
} else if (roll < 0.90) {
// 20% search
searchProducts(data);
} else {
// 10% go to checkout
checkout(data);
}
sleep(Math.random() * 2 + 1); // 1–3s think time between iterations
}
function browseCatalogue(data) {
const res = http.get('https://api.example.com/products', {
headers: { Authorization: `Bearer ${data.token}` },
tags: { name: 'BrowseCatalogue' },
});
check(res, { 'catalogue loaded': (r) => r.status === 200 });
}
function searchProducts(data) {
const terms = ['laptop', 'keyboard', 'monitor', 'headphones'];
const query = terms[Math.floor(Math.random() * terms.length)];
const res = http.get(`https://api.example.com/products?q=${query}`, {
headers: { Authorization: `Bearer ${data.token}` },
tags: { name: 'SearchProducts' },
});
check(res, { 'search returned results': (r) => r.status === 200 });
}
function checkout(data) {
const cartRes = http.post('https://api.example.com/cart/items',
JSON.stringify({ productId: Math.floor(Math.random() * 100) + 1, quantity: 1 }),
{ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` },
tags: { name: 'AddToCart' } }
);
check(cartRes, { 'added to cart': (r) => r.status === 201 });
const orderRes = http.post('https://api.example.com/orders',
JSON.stringify({ paymentMethod: 'card_test' }),
{ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` },
tags: { name: 'PlaceOrder' } }
);
check(orderRes, { 'order placed': (r) => r.status === 201 });
}With the tags in place, your thresholds and Grafana dashboard can show separate metric rows for BrowseCatalogue, SearchProducts, AddToCart, and PlaceOrder.
Grouping requests with group()
group() bundles related requests under a named label in the metrics output. It is the K6 equivalent of a JMeter Transaction Controller:
import { group } from 'k6';
import http from 'k6/http';
import { check } from 'k6';
export default function () {
group('User login flow', function () {
const res = http.post('https://api.example.com/auth/login',
JSON.stringify({ email: 'user@test.com', password: 'pass' }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(res, { 'login succeeded': (r) => r.status === 200 });
});
group('Browse products', function () {
const list = http.get('https://api.example.com/products');
check(list, { 'list loaded': (r) => r.status === 200 });
const detail = http.get('https://api.example.com/products/1');
check(detail, { 'detail loaded': (r) => r.status === 200 });
});
group('Place order', function () {
const order = http.post('https://api.example.com/orders',
JSON.stringify({ productId: 1 }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(order, { 'order created': (r) => r.status === 201 });
});
}In the K6 output, metrics appear under each group label:
group_duration{group::User login flow}......: avg=122ms
group_duration{group::Browse products}......: avg=245ms
group_duration{group::Place order}..........: avg=388msRealistic think times
Think time is the delay between a user completing one action and starting the next. Without it, VUs send requests as fast as the server responds — producing unrealistic concurrency levels.
import { sleep } from 'k6';
export default function () {
// ... make requests ...
// Randomise to avoid synchronised thundering herd
sleep(Math.random() * 3 + 2); // 2–5 seconds — typical web browsing
}Guidelines by user type:
- Interactive web browsing: 2–5 seconds between page loads
- Form interaction (user reading, filling fields): 5–15 seconds
- Mobile app background sync: 1–3 seconds
- API-to-API integrations: 0 (no think time — use arrival-rate executor instead)
⚠️ Common mistakes
- Uniform traffic to one endpoint. A load test that only calls
GET /productsdoes not represent what 100 users simultaneously browsing, searching, and buying will do to the database. Model the workload mix from your analytics. - No sleep() between requests. Without think time, each VU fires requests as fast as the server responds. You end up measuring maximum throughput under bot traffic, not capacity under real user behaviour.
- Groups without tags.
group()adds a label to the output but does not affecthttp_req_durationmetric tags. If you want per-group SLA thresholds, you still needtags: { name: '...' }on each request inside the group. - Ramp-up too short for your target VU count. Jumping to 500 VUs in 10 seconds is a spike test. A load test should ramp slowly enough that the system reaches steady state — typically 2–5% of the target VU count per minute.
🎯 Practice task
Build a proportional workload model against a public API. 40 minutes.
Use https://jsonplaceholder.typicode.com.
- Write a script with the standard load test stage pattern: ramp to 10 VUs over 30s, hold for 2m, ramp to 0 over 30s.
- Inside the default function, use
Math.random()to split traffic: 60% callsGET /posts(taggedListPosts), 30% callsGET /posts/{id}with a random id 1–100 (taggedGetPost), 10% callsPOST /postswith a JSON body (taggedCreatePost). - Add think time:
sleep(Math.random() * 2 + 1)after each flow variant. - Add thresholds for each tagged endpoint:
p(95)<400for ListPosts,p(95)<300for GetPost,p(95)<600for CreatePost. - Run and confirm the traffic split in the output — approximately 60/30/10 across the three metric rows.
- Wrap the three variants in
group()calls with meaningful names. Compare thegroup_durationoutput with the individualhttp_req_durationtagged metrics.