Load Testing — Realistic User Workloads

9 min read

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=388ms

Realistic 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 /products does 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 affect http_req_duration metric tags. If you want per-group SLA thresholds, you still need tags: { 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.

  1. Write a script with the standard load test stage pattern: ramp to 10 VUs over 30s, hold for 2m, ramp to 0 over 30s.
  2. Inside the default function, use Math.random() to split traffic: 60% calls GET /posts (tagged ListPosts), 30% calls GET /posts/{id} with a random id 1–100 (tagged GetPost), 10% calls POST /posts with a JSON body (tagged CreatePost).
  3. Add think time: sleep(Math.random() * 2 + 1) after each flow variant.
  4. Add thresholds for each tagged endpoint: p(95)<400 for ListPosts, p(95)<300 for GetPost, p(95)<600 for CreatePost.
  5. Run and confirm the traffic split in the output — approximately 60/30/10 across the three metric rows.
  6. Wrap the three variants in group() calls with meaningful names. Compare the group_duration output with the individual http_req_duration tagged metrics.

// tip to track lessons you complete and pick up where you left off across devices.