Build a k6 Load Model from Real Traffic, Not a VU Count
In k6's closed-model executors the next iteration waits on the previous one, so a degrading system quietly receives less load and the run still reports green. This walks through converting peak-hour analytics into an arrival rate, driving it with constant-arrival-rate and ramping-arrival-rate, sizing preAllocatedVUs from median iteration duration times rate, and reading dropped_iterations as a capacity finding rather than a misconfiguration. It closes on thresholds — per-flow tag sub-metrics, p(95) and p(99) instead of avg, abortOnFail with delayAbortEval, and the duplicate-key trap that silently discards a gate.

vus is not a measurement of anything. In k6's closed-model executors, the rate at which new iterations start is coupled to how long each iteration takes, so a slower system under test produces less load, which is the opposite of what you wanted. Model traffic instead: convert peak-hour sessions into an arrival rate, drive it with constant-arrival-rate or ramping-arrival-rate, size preAllocatedVUs from median_iteration_duration * rate, and treat dropped_iterations as a result rather than a configuration error.The load test that starts this problem looks like this, and it is in most repositories that have one at all:
export const options = {
vus: 100,
duration: '5m',
};
Ask where 100 came from and the honest answer is usually that 50 felt low and 200 crashed staging. The test runs, the summary is green, and the team reports that the service "handles 100 concurrent users". Nobody can say what that sentence would predict about Black Friday, because the number was never connected to traffic in the first place.
There is a second, quieter problem. That configuration does not hold load steady when the system degrades. It holds it steady only while the system is fast.
Why a VU count silently reduces load when you need it most
k6 groups executors into two models, and the distinction is the single most consequential thing in scenario configuration. constant-vus, ramping-vus, shared-iterations and per-vu-iterations use the closed model: the next iteration for a VU does not start until the previous one finishes. The arrival-rate executors use the open model: iterations start on a schedule, independent of whether earlier ones have completed.
The k6 documentation demonstrates the consequence with an endpoint that takes six seconds:
// Closed model: one VU against a 6s endpoint.
export const options = {
scenarios: {
closed_model: { executor: 'constant-vus', vus: 1, duration: '1m' },
},
};
export default function () {
http.get('https://quickpizza.grafana.com/api/delay/6');
}
// running (1m01.5s), 0/1 VUs, 10 complete and 0 interrupted iterations
Ten iterations in a minute, because 60 divided by 6 is 10. The VU count did not choose the throughput. The response time did. Speed that endpoint up to three seconds and the same config generates twice the load; slow it to twelve and it generates half. k6's own docs name this: the target system's response time influences the throughput of the test, and in the testing literature the problem is known as coordinated omission.
So during the exact window you care about, the one where latency is climbing, a VU-based test backs off. The load generator politely queues behind the system it is supposed to be stressing, and your p95 comes out flattering because the offered rate collapsed along with it.
The open-model version of the same script is a different instrument:
// Open model: the same 6s endpoint, load decided by you.
export const options = {
scenarios: {
open_model: {
executor: 'constant-arrival-rate',
rate: 1,
timeUnit: '1s',
duration: '1m',
preAllocatedVUs: 20,
},
},
};
// running (1m09.3s), 000/011 VUs, 60 complete and 0 interrupted iterations
Sixty iterations, because you asked for one per second for sixty seconds. Note the VU column: k6 used eleven of the twenty pre-allocated VUs to sustain a rate of one per second against a six-second endpoint. VU count became an output of the test, a measure of how much concurrency the system's latency forced you to hold, instead of the input you guessed at.
Turning analytics into a rate you can defend
An arrival rate is only better than a VU count if you got it from somewhere. k6 publishes the formula:
Concurrent users = Hourly sessions * Average session duration (in seconds) / 3600
That is Little's Law wearing analytics clothes: concurrency equals arrival rate multiplied by time spent in the system. Which means you can run it backwards, and running it backwards is the useful direction. Arrival rate is the quantity you configure; concurrency is the quantity the system produces.
The worked example in the k6 guide makes the case for peak-hour data better than an argument would. A site with 2,591 monthly sessions averaging 82 seconds works out to 0.08 average concurrent users across the month. The same site between 3 PM and 4 PM does 990 sessions averaging 92 seconds, which is 25.3 concurrent users. Two orders of magnitude between the average and the hour that actually matters. Any load model built on a monthly figure is testing a moment that never happens.
From the peak hour you get the rate directly. 990 sessions in 3,600 seconds is 0.275 sessions per second. If one k6 iteration represents one user session, that is your rate. Scale by the growth or safety margin the business will sign off on, not by a round number that looks impressive:
// traffic-model.js — every number here traces to a source.
export const PEAK = {
sessionsPerHour: 990, // GA, 15:00-16:00, worst of the last 30 days
avgSessionSeconds: 92, // GA, same window
headroom: 1.5, // agreed capacity margin, not a vibe
};
// Arrival rate is what you configure.
export const sessionsPerSecond = (PEAK.sessionsPerHour / 3600) * PEAK.headroom; // 0.4125
// Concurrency is what the system produces. Useful as a sanity check on
// connection-pool and worker sizing, never as the test's input.
export const expectedConcurrency = sessionsPerSecond * PEAK.avgSessionSeconds; // ~38
Keep the derivation in the repository next to the test. When someone asks in six months why the number is 0.41 and not 5, the file answers.
preAllocatedVUs is a capacity decision, not a load setting
Arrival-rate executors require preAllocatedVUs, and this is where teams new to the open model get bitten, because it looks like the VU knob they just stopped using. It is not. It is the size of the worker pool available to service the schedule. A k6 VU is single-threaded and runs one iteration at a time, so if the schedule wants an iteration and every VU is busy, the iteration cannot run.
k6 gives a sizing formula:
preAllocatedVUs = [median_iteration_duration * rate] + constant_for_variance
which is Little's Law for the third time: concurrency required equals rate times service time, plus a cushion, because the docs are candid that if you knew the iteration duration exactly you would not need to run the test.
Two details that are easy to get wrong. First, maxVUs defaults to the same value as preAllocatedVUs when unset, so leaving it out does not mean "unlimited". Second, the k6 docs actively recommend against setting it in most cases: allocating VUs has CPU and memory costs, and allocating them mid-test can overload the load generator and skew the results you are collecting. On Grafana Cloud, maxVUs counts against your subscription and overrides preAllocatedVUs, because the resources must be reserved whether or not they are ever initialised.
// checkout-peak.js
import http from 'k6/http';
import { sessionsPerSecond } from './traffic-model.js';
const RATE_PER_MINUTE = Math.ceil(sessionsPerSecond * 60); // 25
const MEDIAN_ITERATION_SECONDS = 4.2; // measured in a smoke run
export const options = {
discardResponseBodies: true,
scenarios: {
checkout: {
executor: 'constant-arrival-rate',
rate: RATE_PER_MINUTE,
timeUnit: '1m',
duration: '20m',
// 25/min x 4.2s ~= 1.75 concurrent, x4 for tail variance.
preAllocatedVUs: 8,
// maxVUs deliberately unset: mid-test allocation would perturb the
// measurement, and dropped iterations are the signal we want.
},
},
};
export default function () {
http.get('https://example.com/checkout');
// No sleep() here. The executor already paces iteration starts.
}
That last comment is a documented rule, not a style preference. Arrival-rate executors pace starts through rate and timeUnit, so a trailing sleep() only lengthens each iteration, which inflates the VU requirement without changing the load. Think time between steps inside a multi-request iteration is still legitimate; think time at the end of one is double pacing.
One more mechanical fact worth internalising: iteration starts are spaced fractionally across the time unit. At rate: 10, timeUnit: '1s', k6 starts an iteration roughly every 100ms; at rate: 10, timeUnit: '1m', roughly every six seconds. It does not fire ten requests simultaneously at the top of each period, so an arrival-rate scenario is not a spike test.
Dropped iterations are a measurement, not a misconfiguration
When the schedule calls for an iteration and no VU is free, k6 does not wait. It increments the dropped_iterations counter and moves on. The counter also covers iteration-based executors that hit maxDuration, so read it in context.
The k6 docs split the causes by when the drops appear, and that timing is the whole diagnosis:
- Drops at the start of the run mean the executor configuration is insufficient. You under-allocated. Raise
preAllocatedVUsand run again; nothing has been learned about the system yet. - Drops appearing later, with the rate previously held mean the system under test is degrading. Iterations are taking longer, the pool is saturating, and k6 can no longer start work at the configured rate. That is a finding.
This is why the closed model hides failure and the open model surfaces it. A VU-based test absorbs latency growth as reduced throughput and still reports success. An arrival-rate test converts the same latency growth into a visible, countable number of requests your users would have made and your system would not have served.
Because it is a counter metric, you can gate on it. The docs frame this as an error budget:
thresholds: {
// Fewer than 10 requests the model wanted to send and could not.
dropped_iterations: ['count<10'],
},
Model the shape and the mix, not one flat number
Real peak hours arrive; they do not switch on. ramping-arrival-rate takes stages of target rates rather than target VUs, and the k6 average-load guidance is specific about proportions: the ramp-up should occupy roughly 5–15% of total test duration, and the plateau should last at least five times the ramp-up so you can see a trend rather than a transient.
Scenarios also run in parallel by default, each optionally driving a different exported function via exec and carrying its own tags. That is how you model a mix: browsing traffic and checkout traffic have different rates, different code paths and different latency budgets, and averaging them into one scenario destroys all three.
export const options = {
scenarios: {
browse: {
executor: 'ramping-arrival-rate',
exec: 'browse',
startRate: 60,
timeUnit: '1m',
preAllocatedVUs: 30,
stages: [
{ target: 600, duration: '3m' }, // ramp: ~10% of the run
{ target: 600, duration: '25m' }, // plateau: >5x the ramp
{ target: 60, duration: '2m' },
],
tags: { flow: 'browse' },
},
checkout: {
executor: 'constant-arrival-rate',
exec: 'checkout',
startTime: '3m', // begins once browse is at plateau
rate: 25,
timeUnit: '1m',
duration: '25m',
preAllocatedVUs: 8,
tags: { flow: 'checkout' },
},
},
};
export function browse() { /* ... */ }
export function checkout() { /* ... */ }
startTime is how you sequence otherwise-parallel scenarios; here it holds checkout back until browse traffic is at full rate, so the checkout numbers are measured against a loaded system rather than an idle one. Note also that timeUnit is fixed for the whole scenario. You cannot vary it per stage, so express every stage target in the same unit.
Gate on the percentile you actually promised
A load model without thresholds produces a graph. Thresholds produce a decision: if a threshold expression evaluates to false at the end of the run, k6 fails the test and exits non-zero, commonly 99 for a threshold breach. With no thresholds defined at all, k6 always exits 0, which means an unthresholded test in CI is a job that cannot fail.
Checks do not change this. Checks record assertion outcomes but do not affect the exit status, so a suite of failing checks still exits green unless you put a threshold on the checks metric.
The default trend statistics in the end-of-test summary are avg,min,med,max,p(90),p(95). There is no p(99) unless you ask for one, which is awkward if your SLO is written at p(99). Trend thresholds accept avg, min, max, med and p(N) for any N between 0.0 and 100, with values in milliseconds. A percentile outside that range fails parsing, so the run stops before executing rather than reporting nothing.
export const options = {
summaryTrendStats: ['med', 'p(95)', 'p(99)', 'max', 'count'],
thresholds: {
// Wrong: an average absorbs the tail that generates the complaints.
// http_req_duration: ['avg<500'],
// Right: gate per flow, at the percentile the SLO is written at.
'http_req_duration{flow:checkout}': ['p(95)<800', 'p(99)<2000'],
'http_req_duration{flow:browse}': ['p(95)<400'],
http_req_failed: ['rate<0.01'],
dropped_iterations: ['count<10'],
},
};
For the abort behaviour, use the long form, which takes an object per threshold instead of a string:
thresholds: {
http_req_duration: [
{ threshold: 'p(99)<3000', abortOnFail: true, delayAbortEval: '30s' },
],
},
delayAbortEval exists because a test can breach a threshold in its first seconds and abort before generating meaningful data. On Grafana Cloud, thresholds are evaluated every 60 seconds, so an abort there can lag the breach by up to a minute.
One tagging detail worth knowing: http_req_duration is the sum of http_req_sending, http_req_waiting and http_req_receiving. It excludes DNS lookup and connection setup, which live in http_req_blocked and http_req_connecting. If your gate is meant to represent what a user experiences on a cold connection, http_req_duration alone understates it.
Three ways a correct-looking model still lies
Duplicate metric keys are silently ignored. Thresholds are properties of a JavaScript object, so writing http_req_duration twice means the later definition wins and the earlier one vanishes without a warning. Multiple conditions on one metric belong in the same array: http_req_duration: ['p(95)<400', 'p(99)<1500'].
The plateau never happened. A run whose ramp is long relative to its plateau spends most of its life in transition, and every percentile in the summary is an average over conditions that changed continuously. Check that the sustained phase is at least five times the ramp before you quote a p95 from it.
The tail is still in the report, uncounted. gracefulStop defaults to 30 seconds: at the end of a scenario, k6 stops starting new iterations but waits for in-flight ones to finish. If your iterations regularly run longer than that under load, they are being cut off, and the requests that were slowest are exactly the ones most likely to be truncated.
Apply this now
Take one existing test with a hard-coded vus value. Pull the peak hour for that flow out of your analytics or APM, sessions in the busiest hour and average session duration, and record both in a file beside the test. Divide sessions by 3,600 to get your rate. Multiply rate by a measured median iteration duration to get a starting preAllocatedVUs, then add a cushion.
Convert the scenario to constant-arrival-rate and run it once. Capture three numbers: dropped_iterations, the peak value of the vus gauge, and p(95) per flow. If drops appear immediately, raise preAllocatedVUs and rerun. The run told you nothing about the service. If drops appear only in the second half, you have found a degradation point at a known request rate, which is the first statement about capacity your team can actually put in a document.
Frequently asked questions
Is constant-vus ever the right choice?
Yes, when concurrency is genuinely the thing under test: a fixed pool of long-lived connections, a WebSocket fan-out, a licensed seat limit. In those cases the closed model matches the system. It is the wrong choice whenever you are trying to state a throughput figure, because throughput becomes an output of the system's own latency.
My model is in requests per second, but rate counts iterations. How do I convert?
Divide. If one iteration issues four requests, a target of 40 requests per second is rate: 10. Keeping one iteration equal to one user session is usually cleaner, because session arrival rate is what analytics gives you directly and the request count per session is then just whatever your script does.
The vus gauge climbed to my preAllocatedVUs ceiling. Is that a failure?
Not by itself. Idle VUs are cheap and the executor may use all of them over the course of a run even when it never needs the full number simultaneously. The failure signal is dropped_iterations rising, which means the schedule wanted an iteration and could not get a VU.
Which percentile should the threshold use?
The one your service level objective is written at, and no other. If there is no SLO, gate on p(95) and p(99) together from the observed baseline: p(95) catches broad regressions, p(99) catches a tail that a p(95) gate would let through untouched. Do not gate on avg: a fast median hides a slow tail completely, and the tail is what produces support tickets.
Does the ramp-up period contaminate the percentiles?
It contributes samples from a period when the system was under different load, yes. If that matters, run the ramp as its own scenario and use startTime to begin the measured scenario at plateau, then put the thresholds on tag-filtered sub-metrics so only the measured scenario's samples are evaluated.
Primary references
- k6 — Open and closed models: the coupling of arrival rate to iteration duration in the closed model, the coordinated-omission framing, and the paired 6-second worked examples
- k6 — Constant arrival rate: required options, the fractional spacing of iteration starts, and the instruction not to
sleep()at the end of an iteration - k6 — Ramping arrival rate:
stages,startRate, the fixed-per-scenariotimeUnit, andgracefulStopbehaviour at the end of a scenario - k6 — Arrival-rate VU allocation: the
preAllocatedVUssizing formula, themaxVUsdefault, and why the docs advise againstmaxVUsin most cases - k6 — Dropped iterations: configuration-related versus system-related drops, and using the metric as an error budget
- k6 — Calculate concurrent users for load tests: the concurrency formula and the worked peak-hour example that moves 0.08 concurrent users to 25.3
- k6 — Thresholds: expression syntax, aggregation methods per metric type, tag sub-metrics,
abortOnFailanddelayAbortEval, and the silent loss of duplicate metric keys - k6 — Metrics reference:
dropped_iterations, thevusandvus_maxgauges, and the composition ofhttp_req_duration - k6 — Scenarios: parallel execution,
startTime,exec, per-scenariotags, and the executor taxonomy - k6 — Options reference: the default
summaryTrendStatsofavg,min,med,max,p(90),p(95) - k6 — Average-load testing: the 5–15% ramp-up proportion and the guidance to sustain the plateau at least five times the ramp
- k6 — Verify your thresholds are CI-ready: a run with no thresholds always exits 0, and threshold breaches commonly exit 99