Skip to main content

k6 Scenarios: Executors, Sequencing and Thresholds

k6 scenarios explained with real runs: all 6 executors, startTime chaining, per-scenario thresholds, and a rate test that dropped 686 of 1,001 iterations.

k6 Scenarios: Executors, Sequencing and Thresholds

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-iterations lets fast VUs take most of the work; per-vu-iterations gives 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'].
  • startTime is 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 --vus and --duration replace every scenario in the script. On k6 v2.3.0 so does a top-level vus in options, 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:

OptionWhat it doesDefault
executorWhich scheduler runs this scenario (required)none
startTimeOffset from the start of the test before this scenario begins0s
gracefulStopHow long running iterations may finish after the scenario’s time is up30s
execName of the exported function this scenario runsdefault
envEnvironment variables for this scenario only{}
tagsTags added to every metric from this scenario{}
optionsExtra 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.

ExecutorYou setHolds constantTypical use
shared-iterationsvus, iterationsTotal iterations across all VUsA fixed batch of work, finished as fast as possible
per-vu-iterationsvus, iterationsIterations per VUEach user runs the same journey N times
constant-vusvus, durationConcurrent VUsSoak and baseline tests with a known user count
ramping-vusstartVUs, stagesVU count per stageRamp-up, stress and spike shapes
constant-arrival-raterate, timeUnit, duration, preAllocatedVUsIterations started per time unitAPIs and public sites where arrivals do not wait
ramping-arrival-ratestartRate, stages, preAllocatedVUsIteration rate per stageFinding 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:

ExecutorIterationsVU 1VU 2VUs 3 to 10 (each)Run time
shared-iterations (100 total)100424220.6 s
per-vu-iterations (10 each)1001010103.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:

maxVUsIterations runAchieved rateDroppedMost VUs in useExit code
531515.6/s68650
3099048.8/s11160

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

gracefulStopComplete iterationsInterruptedRun ended at
0s443.0 s
30s (default)805.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 didk6 v2.3.0k6 1.7.1
--vus and --duration CLI flags on a scenarios scriptWarning "cli" level configuration overrode scenarios configuration entirely; one default scenarioSame
The same, with no export default in the scriptExit 104: function 'default' not found in exportsSame
Top-level duration next to scenariosExit 104: using duration and scenarios options simultaneously is not allowedSame
Top-level vus: 10 next to scenariosWarning `vus=10` overrides scenarios configuration; scenarios replacedNo 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.

Bogdan

Founder at LoadFocus

Bogdan builds and runs the tools this blog is about. He writes from what the products actually do in production, including the parts that break.

Weekly tips right in your inbox.

Don’t miss out on the latest testing tips and tricks, tools and news related to software testing.

Already +20,000 members in the list.

The Load Testing Platform with AI That Explains Your Results

Load test websites and APIs from 25 cloud regions, monitor page speed and uptime, and get AI analysis that explains your results in plain English.

Cloud load testing, website speed and API monitoring in one place.

Hi there! Can I share a cool thing I'm working on with you?

A software testing newsletter that sparks joy.

Our goal is to create helpful content for software developers and testers.

If you're not into it, you can unsubscribe instantly.