# 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.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-09-08T13:30:00.000Z

- Updated: 2026-09-07T11:52:04.814Z

- Category: Tools & Reviews

- Canonical source: https://automationtester.in/blog/tools-reviews/generate-k6-load-model-traffic-assumptions

- Tags: k6, load-testing, performance-testing, arrival-rate, capacity-planning, ci-cd

**Quick answer:** A number in `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 — 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 `preAllocatedVUs` and 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 — and 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](https://grafana.com/docs/k6/latest/using-k6/scenarios/concepts/open-vs-closed/): 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](https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/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](https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/ramping-arrival-rate/): `stages`, `startRate`, the fixed-per-scenario `timeUnit`, and `gracefulStop` behaviour at the end of a scenario

- [k6 — Arrival-rate VU allocation](https://grafana.com/docs/k6/latest/using-k6/scenarios/concepts/arrival-rate-vu-allocation/): the `preAllocatedVUs` sizing formula, the `maxVUs` default, and why the docs advise against `maxVUs` in most cases

- [k6 — Dropped iterations](https://grafana.com/docs/k6/latest/using-k6/scenarios/concepts/dropped-iterations/): configuration-related versus system-related drops, and using the metric as an error budget

- [k6 — Calculate concurrent users for load tests](https://grafana.com/docs/k6/latest/testing-guides/calculate-concurrent-users/): the concurrency formula and the worked peak-hour example that moves 0.08 concurrent users to 25.3

- [k6 — Thresholds](https://grafana.com/docs/k6/latest/using-k6/thresholds/): expression syntax, aggregation methods per metric type, tag sub-metrics, `abortOnFail` and `delayAbortEval`, and the silent loss of duplicate metric keys

- [k6 — Metrics reference](https://grafana.com/docs/k6/latest/using-k6/metrics/reference/): `dropped_iterations`, the `vus` and `vus_max` gauges, and the composition of `http_req_duration`

- [k6 — Scenarios](https://grafana.com/docs/k6/latest/using-k6/scenarios/): parallel execution, `startTime`, `exec`, per-scenario `tags`, and the executor taxonomy

- [k6 — Options reference](https://grafana.com/docs/k6/latest/using-k6/k6-options/reference/): the default `summaryTrendStats` of `avg,min,med,max,p(90),p(95)`

- [k6 — Average-load testing](https://grafana.com/docs/k6/latest/testing-guides/test-types/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](https://grafana.com/docs/learning-paths/automate-k6-cicd/verify-thresholds/): a run with no thresholds always exits 0, and threshold breaches commonly exit 99

## Continue reading on AutomationTester.in

- [Test Cron Schedules by Their Fire Times, Not Their Strings](https://automationtester.in/blog/tools-reviews/verify-cron-schedules-time-zones-boundaries)
- [Create a Failure Triage Toolkit Before the Next Flake](https://automationtester.in/blog/tools-reviews/failure-triage-toolkit-before-flake)

Source: [Build a k6 Load Model from Real Traffic, Not a VU Count](https://automationtester.in/blog/tools-reviews/generate-k6-load-model-traffic-assumptions) by Shashank Rawlani.
