When is setting maxVUs above preAllocatedVUs in a k6 arrival-rate scenario worth its cost?
answer
- the ceiling is a budget, not a reserve
- built mid-run, on the loaded machine
- the cloud bills the ceiling
- narrow legitimate uses
- remove it once you know the number
basics
~20 sRarely - k6 builds each VU above preAllocatedVUs mid-run by re-running the init context on the machine already generating load, and in Grafana Cloud maxVUs is what counts against the subscription. Reserve it for first-time sizing.
solid answer
~40 sk6's own documentation heads this section *you probably don't need `maxVUs`*, and the reason is mechanical: a VU beyond `preAllocatedVUs` is constructed while the run is in flight, by executing the script's init context again on the machine that is currently generating load. k6 even logs that build with the note *this may affect test results*. In Grafana Cloud the number that counts against the subscription is `maxVUs`, overriding `preAllocatedVUs`, because the platform must reserve room for VUs that may never exist. The cases where a ceiling earns its keep are narrow: sizing a scenario you have never run, a modest cushion over a number you already trust, and very large distributed runs where you are scaling generators deliberately. Otherwise preallocate what you need and leave `maxVUs` alone.
code
javascript · 21 linesimport http from 'k6/http';
// The committed shape: a measured pool, no ceiling, guarded by a threshold
export const options = {
scenarios: {
committed: {
executor: 'constant-arrival-rate',
rate: 500,
timeUnit: '1s',
duration: '30m',
preAllocatedVUs: 130,
},
},
thresholds: {
dropped_iterations: ['count===0'],
},
};
export default function () {
http.get('https://test.k6.io/');
}go deeper
Take away the default: write preAllocatedVUs and leave maxVUs out unless you have a specific reason. An unset ceiling simply equals the preallocated number.
Explain the mechanism that makes the ceiling expensive - each extra VU is a new runtime built during the run by re-executing the init context, one at a time, on the machine already generating load.
Show the lifecycle you actually run: a ceiling during the first sizing pass, then a measured preAllocatedVUs with the ceiling removed and a dropped_iterations threshold guarding the number.
Argue the tradeoff directly. A fixed pool makes a run's memory footprint and starting state predictable and repeatable; an elastic pool buys tolerance for an unmeasured number and pays in dropped starts and cloud reservation.
## What k6 actually does when it grows a pool `maxVUs` is not a reserve that sits ready. The difference `maxVUs - preAllocatedVUs` is a budget, and k6 spends it lazily, from inside the code path that has just dropped a scheduled start: 1. A start finds no idle VU and is dropped. 2. If budget remains, the executor sends a non-blocking signal to a background goroutine and decrements the budget. 3. That goroutine constructs a brand-new VU - a fresh JavaScript runtime with the script's init context executed again for it - and adds it to the pool. 4. k6 records this at debug level as *Initializing an unplanned VU, this may affect test results*. The costs are therefore paid at the worst possible moment: while the run is already under pressure, on the same machine that is generating the load. ## Why the default posture is "don't" - **The build is not free.** Each VU carries its own runtime and its own per-VU state; constructing one during the run consumes CPU and memory on the k6 process itself. - **The growth is slow and lossy.** One VU per dropped start, one build in flight at a time. A large ceiling does not rescue an undersized floor; it just stretches the climb. - **It hides the signal.** With a big ceiling, the `Insufficient VUs` warning may never fire, so the only evidence is the `dropped_iterations` counter. - **In Grafana Cloud it is what you pay for.** `maxVUs` counts against the subscription and overrides `preAllocatedVUs`, because the platform must provision for the ceiling whether or not it is reached. - **k6's own docs say so.** The guidance is explicit that in almost all cases you should preallocate the number you need beforehand. ## When a ceiling does earn its keep | situation | why the ceiling helps | what to do afterwards | |---|---|---| | first run of a brand-new scenario | you have no measured `iteration_duration` to size against, and a ceiling keeps the run from collapsing | read the summary, recompute, pin `preAllocatedVUs` | | a modest cushion over a trusted number | absorbs drift in iteration duration without a rerun | keep it small - a fraction of the floor, not a multiple | | very large distributed runs | you are scaling generators deliberately and want a hard per-instance cap | remember both numbers are scaled per execution segment | Everything else is better solved by making `preAllocatedVUs` correct. ## What the ceiling does not buy you It is worth being precise about the guarantees a ceiling does *not* provide: - It does not reserve VUs. Nothing above `preAllocatedVUs` exists until a start has already been dropped. - It does not make growth fast. Builds are serialised, one at a time, in the background. - It does not pin which VU runs which iteration. k6 makes no promise about that in any executor, and an arrival-rate scenario may end up using all of the allocated VUs even when it never needed the whole pool to hold the rate. - It does not change the floor. `preAllocatedVUs` remains what k6 constructs before the clock starts. ## The two shapes side by side The sizing run leans on a deliberate ceiling that is meant to be removed: ```javascript export const options = { scenarios: { exploring: { executor: 'constant-arrival-rate', rate: 500, timeUnit: '1s', duration: '2m', preAllocatedVUs: 50, maxVUs: 400, }, }, }; ``` The committed run keeps the number you measured and no ceiling at all: ```javascript export const options = { scenarios: { committed: { executor: 'constant-arrival-rate', rate: 500, timeUnit: '1s', duration: '30m', preAllocatedVUs: 130, }, }, thresholds: { dropped_iterations: ['count===0'] }, }; ``` The second form is the one to keep. With `maxVUs` unset it equals `preAllocatedVUs`, the pool is fixed, and the threshold turns any shortfall into a failed run instead of a quietly short one. ## The judgment behind the choice The question behind this one is which property of a run you want to be stable. A fixed pool makes the run's resource footprint knowable before it starts: you know how much memory k6 will take, and every repeat of the run begins from the same state. An elastic pool trades that for tolerance of a number you did not measure, and pays for it in dropped starts and in a machine whose own workload changes partway through. On a suite that runs on a schedule, the first property is worth far more than the second - so the mature position is to treat `maxVUs` as a tool used while you are still learning a scenario, and to remove it once you know the answer.
- What does k6 have to do to bring a VU beyond preAllocatedVUs into an arrival-rate pool?It constructs a new VU from scratch: a fresh JavaScript runtime with the script's init context executed again for it, built by a background goroutine while the run continues. k6 notes in its debug log that this may affect test results.
- Why does Grafana Cloud charge against maxVUs rather than preAllocatedVUs?Because the platform has to reserve resources for a pool that may reach the ceiling. k6's docs state that maxVUs counts against the subscription and overrides preAllocatedVUs, so an unused ceiling is still paid for.
saying these in an interview costs you the question
- Sets a huge maxVUs as a routine safety margin
- Thinks k6 preallocates the maxVUs headroom cheaply at startup
- Believes an unused ceiling costs nothing in a cloud run
- Uses maxVUs instead of measuring iteration duration
- Assumes a bigger ceiling prevents dropped iterations