Load Model
Table of Contents
- Why the load model comes before the run
- How: from NFR to numbers
- How: executors
- The full script
- How: test types
- How: VUs vs. arrival rate
- How: think time
- Tips
Why the load model comes before the run
A load test script is just a flow with checks; the load model is what turns it into smoke, load, stress, endurance or spike — how many virtual users (or requests/second), following what shape, for how long. Get the load model wrong and every number that comes out of the run is a number for a test you didn't mean to run. 05-load-model.js is one script that can produce all six test types and all six k6 executors (plus a mixed shape) purely from -e switches, so the load model lives entirely in how it's invoked, not in the script itself.
How: from NFR to numbers
NFRs are usually written as either peak concurrent users ("300 concurrent users") or peak throughput ("200 TPS"). k6's stages and executors work in VUs, so a throughput NFR needs converting. Little's Law gives the conversion:
VUs ≈ TPS × (response time + think time)
A worked example against the playground: the load profile's shortened dry run (k6 run -e PROFILE=load -e SCALE=0.05 05-load-model.js, read line by line on the Execute and Analyze page) shows an overall http_req_duration avg of 7.02ms (≈0.007s) with the script's default think time (sleep(THINK), THINK=1). If the NFR is "sustain 10 orders/sec," that's:
VUs ≈ 10 × (0.007 + 1) ≈ 10.1 → 11 VUs
...which is almost exactly USERS=10, the default PEAK in lib/config.js. That's not a coincidence — the default was picked to be a believable peak for this playground, and Little's Law is the sanity check that confirms it. When the NFR is a throughput number rather than a user count, this is also the argument for skipping VUs altogether and using an arrival-rate executor directly (see VUs vs. arrival rate below) — it holds the TPS side of the equation constant instead of the VU side.
How: executors
k6 ships six executors; 05-load-model.js picks one via EXECUTOR, or runs mixed (two of them at once):
| Executor | Shape | When I use it |
|---|---|---|
shared-iterations | A fixed pool of iterations, split across VUs — fast VUs do more | A known total volume of work ("run 100 orders through the system"), not tied to a duration |
per-vu-iterations | Every VU runs the same number of iterations | Comparing VUs on equal footing, or a quick smoke pass with a predictable total |
constant-vus | N VUs, running continuously, for a fixed duration | The closest thing to a plain JMeter Thread Group — "N users for M minutes" |
ramping-vus | VUs move through a list of stages (ramp up, hold, ramp down) | The default — this is how smoke/load/stress/endurance/spike all run |
constant-arrival-rate | Holds a fixed iteration rate, adding VUs as needed to keep up | A "200 TPS" style NFR, independent of how slow the system gets |
ramping-arrival-rate | Like constant-arrival-rate, but the rate itself follows the PROFILE stages | A ramping TPS NFR — the throughput equivalent of ramping-vus |
mixed (not a real k6 executor — two scenarios in one run) | browsers (ramping-vus, never orders) + buyers (constant-arrival-rate, always orders) | Simulating a population that isn't 100% buyers — most traffic just browses |
The full script
k6/sample/05-load-model.js:
import http from 'k6/http';
import { sleep } from 'k6';
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.1.0/index.js';
import { API, PEAK, t, stages } from './lib/config.js';
import { login, authHeaders, resetPlayground } from './lib/auth.js';
import { customerFor } from './lib/data.js';
import { getMenu, createOrder } from './lib/flow.js';
const PROFILE = __ENV.PROFILE || 'smoke';
const EXECUTOR = __ENV.EXECUTOR || 'ramping-vus';
const RATE = Number(__ENV.RATE || 5); // orders per second, for arrival-rate executors
const THINK = Number(__ENV.THINK ?? 1); // seconds; set 0 with arrival-rate executors
const scenarios = {
// Fixed amount of work, split across VUs (fast VUs do more).
'shared-iterations': { executor: 'shared-iterations', vus: PEAK, iterations: PEAK * 10, maxDuration: t(600) },
// Every VU does the same number of iterations.
'per-vu-iterations': { executor: 'per-vu-iterations', vus: PEAK, iterations: 10, maxDuration: t(600) },
// N users for a fixed time. Closest to a plain JMeter Thread Group.
'constant-vus': { executor: 'constant-vus', vus: PEAK, duration: t(300) },
// Users follow the PROFILE shape. This is how smoke/load/stress/endurance/spike run.
'ramping-vus': { executor: 'ramping-vus', startVUs: 0, stages: stages(PROFILE, PEAK), gracefulRampDown: '30s' },
// A fixed request rate, whatever the response time. For "200 TPS" style NFRs.
'constant-arrival-rate': {
executor: 'constant-arrival-rate', rate: RATE, timeUnit: '1s', duration: t(300),
preAllocatedVUs: PEAK, maxVUs: PEAK * 5,
},
// The PROFILE shape, but in orders per second instead of users.
'ramping-arrival-rate': {
executor: 'ramping-arrival-rate', startRate: 0, timeUnit: '1s', stages: stages(PROFILE, RATE),
preAllocatedVUs: PEAK, maxVUs: PEAK * 5,
},
};
// Two populations in one run: browsers who never order, and buyers at a fixed rate.
const mixed = {
browsers: { executor: 'ramping-vus', stages: stages(PROFILE, PEAK), exec: 'browse' },
buyers: {
executor: 'constant-arrival-rate', rate: RATE, timeUnit: '1s', duration: t(300),
preAllocatedVUs: PEAK, maxVUs: PEAK * 5, exec: 'buy',
},
};
function pickScenarios() {
if (EXECUTOR === 'mixed') return mixed;
if (!scenarios[EXECUTOR]) {
throw new Error(`Unknown EXECUTOR "${EXECUTOR}". Use ${Object.keys(scenarios).join(', ')} or mixed.`);
}
return { [EXECUTOR]: { ...scenarios[EXECUTOR], exec: 'buy' } };
}
export const options = {
scenarios: pickScenarios(),
thresholds: {
http_req_duration: ['avg<3000'], // NFR: average response time <= 3s
http_req_failed: ['rate<0.005'], // NFR: error rate <= 0.5%
},
};
export function setup() {
resetPlayground();
}
export function browse() {
getMenu();
sleep(THINK);
}
let token;
export function buy() {
const me = customerFor(__VU);
if (!token) token = login(me.email, me.password);
createOrder(token, me, getMenu(), { autoPay: true });
sleep(THINK);
}
// After the run: the highest order number proves how many orders really landed.
// GET /admin/orders with no status returns only active orders (NEW/PREPARING/READY),
// unpaginated. autoPay orders from this script stay NEW, so the count is complete.
export function teardown() {
const owner = login('owner@playground.local', 'Password123!');
const res = http.get(`${API}/admin/orders`, { headers: authHeaders(owner) });
const numbers = res.json().map((o) => o.orderNumber);
if (numbers.length === 0) {
console.log('no active orders');
return;
}
console.log(`active orders: ${numbers.length}, highest order number: ${Math.max(...numbers)}`);
}
// Replaces the default end-of-test output: keep it on screen and save JSON for the report.
export function handleSummary(data) {
const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '');
return {
stdout: textSummary(data, { indent: ' ', enableColors: true }),
[`results/summary-${PROFILE}-${stamp}.json`]: JSON.stringify(data, null, 2),
};
}
EXECUTOR picks which of the scenarios entries actually runs (or the mixed pair); PROFILE only matters for ramping-vus/ramping-arrival-rate, where it selects a stage shape from lib/config.js's stages(). handleSummary and its textSummary() import are covered on the Reporting page.
How: test types
All six test types from Performance Testing Fundamentals come from the same ramping-vus scenario — only PROFILE and USERS (the peak) change. Mapping Test Types to k6 has the short, tool-agnostic version of this table; below is the same six types run against this specific script, with the SCALE dry run I actually execute before a long test next to the real, full-length command.
| Type | Dry run (what I ran) | Real command | Dry run result |
|---|---|---|---|
| Smoke | k6 run -e PROFILE=smoke -e SCALE=0.02 05-load-model.js | k6 run -e PROFILE=smoke 05-load-model.js (default USERS=10, ~1 min — short enough that "dry" and "real" are almost the same run) | 4 iterations, 16 reqs, 0% failed, ~2.3s |
| Load | k6 run -e PROFILE=load -e SCALE=0.02 -e USERS=5 05-load-model.js | k6 run -e PROFILE=load -e USERS=<NFR peak> 05-load-model.js (~31.5 min unscaled: 30s + 1800s + 60s) | 185 iterations, 379 reqs, 0% failed, avg 7.06ms, p95 10.19ms, ~39s |
| Stress | k6 run -e PROFILE=stress -e SCALE=0.02 -e USERS=5 05-load-model.js | k6 run -e PROFILE=stress -e USERS=<NFR peak> 05-load-model.js (~23 min unscaled) | 193 iterations, 400 reqs, 0% failed, avg 8.89ms, p95 14.08ms, ~27s, peaked at 10 VUs |
| Endurance | k6 run -e PROFILE=endurance -e SCALE=0.01 -e USERS=5 05-load-model.js | k6 run -e PROFILE=endurance -e USERS=<NFR peak> 05-load-model.js (~4h15m unscaled) | 731 iterations, 1471 reqs, 0% failed, avg 8.02ms, p95 15.81ms, ~2m33s |
| Spike | k6 run -e PROFILE=spike -e SCALE=0.02 -e USERS=5 05-load-model.js | k6 run -e PROFILE=spike -e USERS=<NFR peak> 05-load-model.js (~8 min unscaled) | 71 iterations, 156 reqs, 0% failed, avg 12.9ms, p95 108.93ms, ~13s, peaked at 10 VUs |
| Scalability | Same stress command as above | The stress command again, unchanged, after each infrastructure change (more app instances, a bigger DB) — the comparison is capacity between runs, not inside one | n/a — same numbers as Stress, repeated per infra size |
Every dry run above passed both thresholds (http_req_duration avg<3000, http_req_failed rate<0.005) and printed 0% http_req_failed, which is expected against an idle playground on a laptop — the point of the dry run isn't to prove the NFR, it's to prove the shape runs end-to-end (setup() resets cleanly, every stage executes, teardown() reports sane numbers) before spending real wall-clock time on the full-length version.
How: VUs vs. arrival rate
X-Mock-Delay only slows the mock payment gateway, and the gateway is only called on the non-autoPay path (OrderService.create returns before calling it when autoPay is true) — so this demo uses 03-order-flow.js with AUTOPAY=false, not 05-load-model.js (which always auto-pays).
Fixed VUs, gateway at normal speed — k6 run --vus 5 --duration 30s -e AUTOPAY=false 03-order-flow.js:
iterations.....................: 140 4.55904/s
http_reqs......................: 567 18.464113/s
http_req_duration..............: avg=22.13ms min=735µs med=11.61ms max=252.51ms
Same fixed VUs, gateway slowed by 1s — k6 run --vus 5 --duration 30s -e AUTOPAY=false -e MOCK_DELAY=1000 03-order-flow.js:
iterations.....................: 75 2.391679/s
http_reqs......................: 307 9.789939/s
http_req_duration..............: avg=263.23ms min=770µs med=12.37ms max=1.08s
Same 5 VUs, same 30 seconds — throughput drops from 4.56 to 2.39 iterations/s (almost half) purely because each VU now spends longer per iteration waiting on the gateway. That's the problem with fixed VUs against a throughput NFR: VUs control concurrency, not rate, so a slower system quietly serves less traffic with the same VU count instead of the test noticing anything is wrong.
An arrival-rate executor (constant-arrival-rate/ramping-arrival-rate) fixes this by holding the rate constant and adding VUs to compensate as responses slow down — up to maxVUs.
How: think time
sleep(THINK) in buy()/browse() is fixed think time — every iteration waits exactly THINK seconds. A more human pattern is randomised think time, e.g. sleep(1 + Math.random() * 2) for "1 to 3 seconds, uniformly" — closer to how real users pause between actions, at the cost of a run that's harder to reproduce exactly.
Arrival-rate runs in this script are invoked with THINK=0. That's deliberate: constant-arrival-rate/ramping-arrival-rate already enforce the target rate at the executor level, so think time inside the iteration doesn't slow the test down — it just means each VU is busy longer per iteration, which the executor compensates for by spinning up more VUs (up to maxVUs) to keep hitting the rate. Setting THINK=0 keeps VU usage efficient for a rate that's meant to represent machine-to-machine order placement, not a browsing pause.
Tips
k6 inspectbefore any run that costs more than a few seconds. It validates the script and prints the resolved options — including every stage — without sending a single request:A typo in$ k6 inspect -e PROFILE=lod 05-load-model.js; echo exit=$?Error: Unknown PROFILE "lod". Use smoke, load, stress, endurance or spike.exit=107PROFILEorEXECUTORfails loudly here instead of quietly running with k6's own default (1 VU) or, worse, three hours into an endurance test.- The
SCALEdry run is a habit, not a one-off. Every row in the test-types table above was run atSCALE=0.01–0.02first — a 4-hour endurance test becomes 2.5 minutes, a 30-minute load test becomes under a minute — before the real, full-length command ever runs. It catches a brokensetup(), a bad threshold, or a wrongEXECUTORfor the price of a coffee break instead of the whole test window. gracefulRampDown: '30s'(onramping-vus) gives in-flight iterations 30 seconds to finish naturally when a stage ramps VUs down, instead of k6 cutting them off mid-request — the↓marker in the CLI progress bar (ramping-vus ↓ [ 100% ]) is k6 showing that ramp-down grace period in progress.maxVUsis a real ceiling on an arrival-rate executor. Push the target rate past whatmaxVUsallows (e.g.preAllocatedVUs: 1, maxVUs: 5withRATE=100against a plain 12ms endpoint) and k6 logsInsufficient VUs, reached 5 active VUs and cannot initialize morewith non-zerodropped_iterations— those are iterations the executor wanted to start and couldn't, a capacity finding in its own right ("this system needs at least N VUs worth of concurrency to sustain the target TPS"), not a script bug.THINK=0onconstant-vus/browsersmeans no pacing at all. Both usesleep(THINK)with nothing else capping request rate, so a 30-secondconstant-vusrun atSCALE=0.1withTHINK=0produced 28,377 orders — expected, not a bug, if you copy these commands directly.