Skip to main content

Load Model

Table of Contents

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):

The six k6 executors at a glance: shared-iterations and per-vu-iterations fix iteration counts, constant-vus and ramping-vus hold VUs, constant-arrival-rate and ramping-arrival-rate hold iterations per second; plus closed vs open model, where fixed VUs lose throughput when the server slows and arrival rate adds VUs instead

ExecutorShapeWhen I use it
shared-iterationsA fixed pool of iterations, split across VUs — fast VUs do moreA known total volume of work ("run 100 orders through the system"), not tied to a duration
per-vu-iterationsEvery VU runs the same number of iterationsComparing VUs on equal footing, or a quick smoke pass with a predictable total
constant-vusN VUs, running continuously, for a fixed durationThe closest thing to a plain JMeter Thread Group — "N users for M minutes"
ramping-vusVUs 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-rateHolds a fixed iteration rate, adding VUs as needed to keep upA "200 TPS" style NFR, independent of how slow the system gets
ramping-arrival-rateLike constant-arrival-rate, but the rate itself follows the PROFILE stagesA 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.

TypeDry run (what I ran)Real commandDry run result
Smokek6 run -e PROFILE=smoke -e SCALE=0.02 05-load-model.jsk6 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
Loadk6 run -e PROFILE=load -e SCALE=0.02 -e USERS=5 05-load-model.jsk6 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
Stressk6 run -e PROFILE=stress -e SCALE=0.02 -e USERS=5 05-load-model.jsk6 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
Endurancek6 run -e PROFILE=endurance -e SCALE=0.01 -e USERS=5 05-load-model.jsk6 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
Spikek6 run -e PROFILE=spike -e SCALE=0.02 -e USERS=5 05-load-model.jsk6 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
ScalabilitySame stress command as aboveThe stress command again, unchanged, after each infrastructure change (more app instances, a bigger DB) — the comparison is capacity between runs, not inside onen/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 inspect before 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:
    $ k6 inspect -e PROFILE=lod 05-load-model.js; echo exit=$?
    Error: Unknown PROFILE "lod". Use smoke, load, stress, endurance or spike.
    exit=107
    A typo in PROFILE or EXECUTOR fails loudly here instead of quietly running with k6's own default (1 VU) or, worse, three hours into an endurance test.
  • The SCALE dry run is a habit, not a one-off. Every row in the test-types table above was run at SCALE=0.01–0.02 first — 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 broken setup(), a bad threshold, or a wrong EXECUTOR for the price of a coffee break instead of the whole test window.
  • gracefulRampDown: '30s' (on ramping-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.
  • maxVUs is a real ceiling on an arrival-rate executor. Push the target rate past what maxVUs allows (e.g. preAllocatedVUs: 1, maxVUs: 5 with RATE=100 against a plain 12ms endpoint) and k6 logs Insufficient VUs, reached 5 active VUs and cannot initialize more with non-zero dropped_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=0 on constant-vus/browsers means no pacing at all. Both use sleep(THINK) with nothing else capping request rate, so a 30-second constant-vus run at SCALE=0.1 with THINK=0 produced 28,377 orders — expected, not a bug, if you copy these commands directly.