Composing Realistic Load Profiles with k6 Scenarios and Executors
Learn how to compose realistic, multi-protocol load profiles in a single k6 script using scenarios and executors. Includes a worked example with HTTP, WebSocket, and gRPC flows, plus trade-offs like arrival-rate overshoot and graceful-stop cutoffs.
05 Feb 2026, 21:44 UTC

The problem: one script, many traffic patterns
Most load tests start simple: hammer an endpoint with a constant stream of requests. Real systems, however, face a mix of steady API traffic, sudden checkout spikes, and long-running background jobs — often over different protocols. Running separate scripts for each flow fragments your CI pipeline and makes it hard to see how the combined load affects shared resources.
k6 scenarios solve this by letting you define multiple independent load profiles in a single script. Each scenario gets its own executor (the VU scheduling strategy), start offset, and protocol, while sharing the same test lifecycle. The result is a realistic, composable load model that runs locally and in distributed cloud executions without rewrites.
Scenarios: independent load profiles in one script
A scenario is a named block in the exported options.scenarios object. It declares:
- executor — the scheduling algorithm (see below)
- startTime — delay before this scenario begins (e.g., "30s")
- tags — key/value pairs automatically attached to every metric sample from this scenario
- executor-specific knobs like
vus,iterations, orrate
Because tags flow into metrics, you can filter Grafana dashboards or write thresholds per scenario (e.g., http_req_duration{scenario:checkout} < 500). The gracefulStop and gracefulRampDown settings also apply per scenario, letting you drain in-flight requests cleanly at phase boundaries.
Choosing the right executor for each flow
k6 ships with five executors, each implementing a different VU scheduling model:
| Executor | Model | Best for |
|---|---|---|
shared-iterations | Closed | Fixed total iterations split across VUs; good for "run N requests total" |
per-vu-iterations | Closed | Each VU runs a fixed iteration count; predictable per-VU work |
constant-vus | Closed | Hold a steady VU count; classic constant concurrency |
ramping-vus | Closed | Ramp VUs up/down through stages; simulates traffic waves |
arrival-rate | Open | Target a request rate regardless of latency; mimics real-world arrival streams |
Pick closed-model executors when you control concurrency (e.g., a fixed pool of virtual users). Use arrival-rate when you want to stress a system at a specific throughput, but set maxVUs conservatively — if the system stalls, k6 will spawn VUs up to that limit to catch up, potentially amplifying the load.
Worked example: mixed HTTP, WebSocket, and gRPC traffic
The script below defines three scenarios that run concurrently:
- browse — steady HTTP API traffic at 100 VUs
- checkout — a 30-second spike at 200 iterations/sec using
arrival-rate - background — a long-running gRPC job with 10 VUs each doing 50 iterations
import http from 'k6/http';
import ws from 'k6/ws';
import grpc from 'k6/net/grpc';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
browse: {
executor: 'constant-vus',
vus: 100,
duration: '2m',
tags: { scenario: 'browse' },
exec: 'browseFlow',
},
checkout: {
executor: 'arrival-rate',
rate: 200,
timeUnit: '1s',
duration: '30s',
startTime: '30s',
preAllocatedVUs: 50,
maxVUs: 300,
tags: { scenario: 'checkout' },
exec: 'checkoutFlow',
},
background: {
executor: 'per-vu-iterations',
vus: 10,
iterations: 50,
startTime: '10s',
tags: { scenario: 'background' },
exec: 'backgroundFlow',
},
},
thresholds: {
'http_req_duration{scenario:checkout}': ['p(95)<500'],
'grpc_req_duration{scenario:background}': ['p(99)<1000'],
},
};
export function browseFlow() {
const res = http.get('https://api.example.com/products');
check(res, { 'status 200': (r) => r.status === 200 });
sleep(1);
}
export function checkoutFlow() {
const payload = JSON.stringify({ items: ['sku-123'], user: 'test' });
const res = http.post('https://api.example.com/checkout', payload, {
headers: { 'Content-Type': 'application/json' },
});
check(res, { 'order placed': (r) => r.status === 201 });
}
export function backgroundFlow() {
const client = new grpc.Client();
client.load([], 'proto/batch.proto');
client.connect('grpc.example.com:443', { plaintext: false });
const response = client.invoke('batch.Process', { jobId: `job-${__VU}-${__ITER}` });
check(response, { 'grpc ok': (r) => r.status === grpc.StatusOK });
client.close();
sleep(0.5);
}
Run it locally with:
k6 run --summary-export=summary.json script.js
Then inspect the JSON for per-scenario metrics (look for the scenario tag in each sample). You can also preview the resolved execution plan before running:
k6 inspect --execution-script script.js
Both commands require only read access to the script and a working k6 binary (v0.50+). No elevated permissions are needed.
Trade-offs and limitations
- Arrival-rate overshoot: If the system under test stalls, k6 spawns VUs up to
maxVUsto meet the target rate. SetmaxVUsto a safe ceiling. - Shared-iterations distribution: When total iterations < VUs, some VUs exit immediately — not an error, but it reduces concurrency.
- Custom metrics need manual tags: Scenario tags are not auto-applied to custom metrics; add
tags: { scenario: 'checkout' }in yourtrend.add()calls. - Graceful stop cutoff: The default 30s
gracefulStopmay abort long requests (e.g., file uploads), inflating error rates. Increase it per scenario if needed. - Local resource exhaustion: Many high-VU scenarios can hit file-descriptor or port limits. Use
--no-usage-reportand raise ulimits for heavy local runs.
Actionable next steps
- Add a
scenariosblock to your existing script and move each logical flow into its ownexecfunction. - Tag each scenario and write at least one scenario-scoped threshold.
- Run
k6 inspect --execution-scriptto verify the resolved config. - Execute locally with
--summary-exportand confirm per-scenario metrics appear in the JSON. - Promote the same script to CI or Grafana Cloud k6 — scenario names and tags will match exactly.
By modeling each traffic pattern as a first-class scenario, you get realistic load, per-flow pass/fail criteria, and a single source of truth for local and cloud runs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.