k6 scenarios are named blocks under options.scenarios that each decide how virtual users and iterations are scheduled: which executor drives the load, when the block starts, which exported function it runs, and with which tags and environment variables. One script can hold several, running in parallel or one after another, each with its own load shape.
This guide runs every executor in k6 v2.3.0 against a small local API and reports what happened. The results worth knowing before you write your own: shared-iterations gave one VU 42 iterations and another 2, an arrival-rate scenario with too few VUs ran 315 of 1,001 planned iterations and the test still exited 0, and a top-level vus option replaced a whole scenarios block with only a warning in the log.
Key Takeaways
- A scenario is an executor plus its settings. Six executors ship with k6 v2: two count iterations, two count VUs, two count arrival rate.
shared-iterationslets fast VUs take most of the work;per-vu-iterationsgives every VU exactly the same count.- Arrival-rate executors drop iterations when they run out of VUs, and a test with drops still passes unless a threshold says otherwise. Add
dropped_iterations: ['count==0']. startTimeis the only way to run scenarios one after another, and thresholds filtered by{scenario:name}let each one pass or fail on its own.- CLI flags
--vusand--durationreplace every scenario in the script. On k6 v2.3.0 so does a top-levelvusinoptions, which k6 1.7.1 ignored.
What are scenarios in k6?
A scenario is an entry in the scenarios object of options. Its key is a name you choose, and its value names an executor and that executor’s settings. The name shows up in the run summary and as a scenario tag on every metric, which is what makes per-scenario results possible. Without a scenarios block, k6 builds one scenario called default from the top-level vus, duration, iterations or stages.
export const options = {
scenarios: {
browse: {
executor: 'constant-vus',
vus: 5,
duration: '10s',
exec: 'browse',
tags: { flow: 'browse' },
},
},
};
export function browse() { /* requests */ }
Every executor accepts the same common options. The defaults below are from the scenarios page of the k6 docs:
| Option | What it does | Default |
|---|---|---|
executor | Which scheduler runs this scenario (required) | none |
startTime | Offset from the start of the test before this scenario begins | 0s |
gracefulStop | How long running iterations may finish after the scenario’s time is up | 30s |
exec | Name of the exported function this scenario runs | default |
env | Environment variables for this scenario only | {} |
tags | Tags added to every metric from this scenario | {} |
options | Extra options, such as browser settings | {} |
Which k6 executor should you use?
Pick by what you want to hold constant. If the goal is a fixed amount of work, use an iterations executor. If it is a fixed number of concurrent users, use a VU executor. If it is a fixed rate of new requests whatever the server’s speed, use an arrival-rate executor. The iterations and VU executors (the first four rows below) are closed models, where a slow server receives less load; the two arrival-rate executors are open models.
| Executor | You set | Holds constant | Typical use |
|---|---|---|---|
shared-iterations | vus, iterations | Total iterations across all VUs | A fixed batch of work, finished as fast as possible |
per-vu-iterations | vus, iterations | Iterations per VU | Each user runs the same journey N times |
constant-vus | vus, duration | Concurrent VUs | Soak and baseline tests with a known user count |
ramping-vus | startVUs, stages | VU count per stage | Ramp-up, stress and spike shapes |
constant-arrival-rate | rate, timeUnit, duration, preAllocatedVUs | Iterations started per time unit | APIs and public sites where arrivals do not wait |
ramping-arrival-rate | startRate, stages, preAllocatedVUs | Iteration rate per stage | Finding the request rate where latency breaks |
We ran ramping-vus from 0 to 8 VUs over 4 seconds, held 8 for 4 seconds and ramped down over 2: sampled once a second, the VU count read 1, 3, 5, 7, 8, 8, 8, 8, 6, 2. Each of the other five appears in a measured run in the sections below.
Older guides list a seventh, externally-controlled, which took its VU count from k6’s REST API during the run. It was removed in k6 v2.0 (GitHub pull request 5846). We ran one script using it on both versions: k6 1.7.1 started it and accepted a change from 2 to 7 VUs mid-run, while k6 v2.3.0 stopped with unknown executor type and exit code 104.
What is the difference between shared-iterations and per-vu-iterations?
Both stop after a number of iterations, but they split the work differently. shared-iterations puts all iterations in one pool and any free VU takes the next one, so fast VUs end up doing most of it. per-vu-iterations gives each VU its own quota, so every VU does exactly the same count and the slowest one decides when the scenario ends.
We gave 10 VUs 100 iterations in each mode. VUs 1 and 2 called an endpoint that answers in 10 ms; VUs 3 to 10 called one that answers in 300 ms:
| Executor | Iterations | VU 1 | VU 2 | VUs 3 to 10 (each) | Run time |
|---|---|---|---|---|---|
shared-iterations (100 total) | 100 | 42 | 42 | 2 | 0.6 s |
per-vu-iterations (10 each) | 100 | 10 | 10 | 10 | 3.0 s |
The same 100 iterations, two very different tests. With shared iterations, 84% of the requests went to the fast endpoint and the run said little about the slow one. Use per-vu-iterations when every user must run the full journey, and shared-iterations for a batch job where only the total matters. Both stop at maxDuration (default 10 minutes) and drop whatever is left.
How do arrival-rate executors work, and what are dropped iterations?
An arrival-rate executor starts a set number of iterations per timeUnit, whatever the response times. Each iteration needs a free VU. When every VU is busy, k6 cannot start the iteration on time, skips it, and adds one to the dropped_iterations counter. preAllocatedVUs is how many VUs k6 creates before the test; maxVUs is the most the scenario may have in total, pre-allocated ones included.
We ran constant-arrival-rate at 50 iterations a second for 20 seconds against the 300 ms endpoint, which k6 counted as 1,001 iteration starts, with 5 pre-allocated VUs:
maxVUs | Iterations run | Achieved rate | Dropped | Most VUs in use | Exit code |
|---|---|---|---|---|---|
| 5 | 315 | 15.6/s | 686 | 5 | 0 |
| 30 | 990 | 48.8/s | 11 | 16 | 0 |
With maxVUs: 5, the test ran at under a third of its target rate. The only signs were a warning in the log, Insufficient VUs, reached 5 active VUs and cannot initialize more, and the dropped_iterations line in the summary. The exit code was 0, so a CI job would have passed it. With room to grow, k6 used 15 to 16 VUs, close to the rate times the response time (50 × 0.3 s = 15), and all 11 drops came in the first 1.2 seconds, while it was still adding VUs.
Two habits follow. Size preAllocatedVUs at rate × expected response time, with maxVUs well above it. And make drops fail the run:
thresholds: {
dropped_iterations: ['count==0'],
},
The k6 docs give two causes for drops: an executor configuration that is insufficient (for arrival-rate executors, too few VUs), or a system under test that slowed down until iterations took longer than planned. Drops that start partway through a test are usually the second kind, and they are a finding about the server, not about the script.
How do you run multiple scenarios in one k6 script?
Add more keys under scenarios and point each at its own exported function with exec. Scenarios are independent: each has its own executor, VUs, tags and environment variables, and by default they all start at once. A script with scenarios that all name an exec function needs no export default.
This is the script we used for the sequencing, threshold and LoadFocus tests below. A browsing flow runs at 5 constant VUs, and a checkout flow ramps by arrival rate:
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
browse: {
executor: 'constant-vus', vus: 5, duration: '10s',
exec: 'browse', tags: { flow: 'browse' },
},
checkout: {
executor: 'ramping-arrival-rate', startRate: 1, timeUnit: '1s',
preAllocatedVUs: 5, maxVUs: 20,
stages: [{ target: 10, duration: '5s' }, { target: 10, duration: '5s' }],
startTime: '10s', exec: 'checkout', env: { CART: '3' },
},
},
thresholds: {
'http_req_duration{scenario:browse}': ['p(95)<100'],
'http_req_duration{scenario:checkout}': ['p(95)<250'],
dropped_iterations: ['count==0'],
},
};
export function browse() {
http.get('http://127.0.0.1:8099/fast');
}
export function checkout() {
const res = http.get('http://127.0.0.1:8099/slow?items=' + __ENV.CART);
check(res, { 'status 200': (r) => r.status === 200 });
}
k6 prints each scenario’s plan before it starts, which is the quickest way to check a configuration did what you meant:
scenarios: (100.00%) 2 scenarios, 25 max VUs, 50s max duration (incl. graceful stop):
* browse: 5 looping VUs for 10s (exec: browse, gracefulStop: 30s)
* checkout: Up to 10.00 iterations/s for 10s over 2 stages (maxVUs: 5-20, exec: checkout, startTime: 10s, gracefulStop: 30s)
The env value is visible only inside the checkout scenario, as __ENV.CART. The tags value is added to every metric the browse scenario emits, next to the automatic scenario tag.
How do you run k6 scenarios one after another?
Set startTime on each later scenario to when the one before should be finished. k6 has no “after scenario X” setting: scenarios are scheduled from the start of the test, so sequencing is a matter of offsets. Add the earlier scenario’s duration, and leave room for its gracefulStop if its iterations can run long.
In the script above, browse runs for 10 seconds and checkout starts at 10 seconds, so the whole run took 20.3 seconds by k6’s clock and the two never overlapped. The k6 docs put it the same way: scenarios run in parallel, and startTime only makes them appear sequential. If browse’s last iterations overrun into its grace period, they will overlap checkout’s first seconds. For a strict warm-up phase before measuring, a gap of a few seconds between the two is cheap insurance.
How do you set thresholds per scenario in k6?
Filter the metric by the automatic scenario tag: 'http_req_duration{scenario:checkout}': ['p(95)<250']. k6 evaluates that threshold only on samples from the checkout scenario, so a slow checkout fails the run even when browsing is fast enough to pull the overall p95 down. Any tag works the same way, including the ones set in a scenario’s tags.
Our checkout endpoint takes 300 ms, so the 250 ms threshold failed while the browse one passed:
http_req_duration{scenario:browse}
✓ 'p(95)<100' p(95)=11.56ms
http_req_duration{scenario:checkout}
✗ 'p(95)<250' p(95)=302.25ms
k6 logged thresholds on metrics 'http_req_duration{scenario:checkout}' have been crossed and exited with code 99, the code CI tools read as a failed test. Raising the limit to 400 ms gave exit code 0. A threshold on the unfiltered http_req_duration would have blended both flows: more than 4,000 browse requests at about 10 ms each against 77 checkouts. Our guide to why percentiles beat averages explains why a blended p95 hides the slow path. Add abortOnFail: true to a threshold to stop the test the moment it fails.
What does gracefulStop do in a k6 scenario?
gracefulStop is how long k6 waits, after a scenario’s duration or iteration budget ends, for iterations already running to finish. When that grace period runs out, k6 interrupts them. The default is 30 seconds. Interrupted iterations are not counted in the iterations metric.
We ran 4 VUs for 3 seconds, each iteration a request followed by sleep(2.5):
gracefulStop | Complete iterations | Interrupted | Run ended at |
|---|---|---|---|
0s | 4 | 4 | 3.0 s |
30s (default) | 8 | 0 | 5.0 s |
With no grace period, half the iterations were cut off and disappeared from the iterations count. Keep the default for user journeys with think time, and shorten it only when a long tail of slow iterations would blur the results of the next scenario. ramping-vus has a second setting, gracefulRampDown (also 30 seconds by default), for VUs removed by a ramp-down stage.
Why does k6 ignore my scenarios?
Because a load option from the CLI or the top level of options replaced them, or clashed with them so k6 refused to start. k6 merges options from the script, environment variables and CLI flags, and the load-shape shortcuts (vus, duration, iterations, stages) do not mix with scenarios. We tested each combination on k6 v2.3.0 and k6 1.7.1 (exit codes read from the shell):
| What we did | k6 v2.3.0 | k6 1.7.1 |
|---|---|---|
--vus and --duration CLI flags on a scenarios script | Warning "cli" level configuration overrode scenarios configuration entirely; one default scenario | Same |
The same, with no export default in the script | Exit 104: function 'default' not found in exports | Same |
Top-level duration next to scenarios | Exit 104: using duration and scenarios options simultaneously is not allowed | Same |
Top-level vus: 10 next to scenarios | Warning `vus=10` overrides scenarios configuration; scenarios replaced | No warning; vus ignored, scenarios ran |
The last row is the one that bites during an upgrade. A script that carried a leftover vus line ran its scenarios on k6 1.7.1 and ran a single 10-VU shared-iterations scenario on v2.3.0, with only a warning to say so. When a scenarios script misbehaves, read the scenarios: lines k6 prints at the start before reading any results.
How does LoadFocus run k6 scripts that use scenarios?
LoadFocus keeps the structure of a scenarios script and replaces its load shape with the run settings. When you upload the script, the LoadFocus upload check notes that LoadFocus runs every scenario and shares the test’s users between them in the same proportions as the script, and that the test settings replace the script’s own users, durations and start times. Arrival-rate scenarios run with a fixed number of users rather than a fixed request rate.
We ran the script above through the wrapper the LoadFocus k6 agent uses, set to 50 users for 5 minutes, and read the result with k6 inspect. Browse and checkout each got 25 users (the script gave browse 5 VUs and checkout 5 pre-allocated VUs, an even split) on constant-vus for 5 minutes, both starting at once. Each scenario kept its exec function, its env and its tags, and all three thresholds, including the two per-scenario ones, came through unchanged, so per-scenario pass or fail still works. What you give up is startTime sequencing and the open model: on LoadFocus the ramp, duration and user count come from the run settings. The test runs from 25 cloud locations. See k6 load testing on LoadFocus, and the pass/fail thresholds guide for thresholds set in the app instead of the script.
Frequently Asked Questions
How many scenarios can a k6 script have?
The k6 documentation on scenarios names no maximum, only that each scenario name in a script must be unique. Each scenario adds its own VUs to the total, so the real limit is the memory and CPU of the machine running k6, which has to hold the peak VUs of every scenario that overlaps in time.
Do k6 scenarios run in parallel or in sequence?
They run in parallel. Every scenario starts when the test starts unless it has a startTime, so running them in sequence means giving each later scenario a startTime equal to the time the earlier ones should be done, plus a margin for their graceful stop period.
What is the default executor in k6?
It depends on the top-level options, because k6 builds a single scenario named default from them when a script has no scenarios block. Vus with iterations gives shared-iterations, vus with duration gives constant-vus, and stages gives ramping-vus. With none of them set, it runs per-vu-iterations with one VU and one iteration.
Why does my k6 test pass when the target rate was not reached?
Because dropped iterations do not fail a test on their own. When an arrival-rate scenario has no free VU at the moment an iteration is due, k6 skips it and counts it as dropped, logs a warning, and still exits 0. Add a threshold of count==0 on the dropped_iterations metric so the run fails instead.
What is the difference between constant-vus and constant-arrival-rate?
constant-vus keeps a fixed number of users busy, and each starts its next iteration only when the last one ends, so a slower server receives fewer requests. constant-arrival-rate starts a fixed number of iterations per second whatever the response times, adding VUs up to maxVUs, so a slower server receives the same load and the slowdown shows in latency.
Sources
- Scenarios, the scenario options table, executors, dropped iterations, graceful stop and thresholds: Grafana k6 documentation, grafana.com/docs/k6/latest/using-k6/scenarios/ and its executor and concepts pages, and grafana.com/docs/k6/latest/using-k6/thresholds/, read 2 November 2026.
- Removal of the externally-controlled executor in k6 v2.0: grafana/k6 GitHub issue 5640 and pull request 5846 (milestone v2.0.0-rc1); k6 v2.3.0 released 21 September 2026 (github.com/grafana/k6/releases), read 2 November 2026.
- LoadFocus k6 runs: loadfocus.com/k6-load-testing, read 2 November 2026; the upload check’s wording about scenarios and the agent’s scenario wrapper, from the LoadFocus source code, 2 November 2026.
- Test runs: k6 v2.3.0 (official macOS arm64 release, checksum-verified) and k6 v1.7.1, against a local Node.js 23.11.1 test API with 10 ms and 300 ms endpoints, on an Apple M1 Max, 2 November 2026. Timings show patterns, not benchmarks.