Configure k6 executor types and multi-scenario scripts to run all five performance test types, model open workloads with arrival-rate executors, and identify system saturation.
Most real-world performance requirements need more than one test type. You want to verify normal load, find the breaking point, and confirm burst handling — ideally in one CI job. k6 scenarios compose multiple executor types in a single script with independent VU pools, start times, and thresholds.
The executor choice is critical: constant-vus models a closed workload (N users hammering the server), while constant-arrival-rate models an open workload (N requests per second arriving regardless of server speed). REST APIs typically receive open workloads in production — arrival-rate executors model this correctly.
k6 ships six executors. Three cover most use cases:
constant-vus: N virtual users loop for the duration. Total RPS = VUs / avg_iteration_time. If the server slows and iteration time grows, RPS drops. Closed workload model.
ramping-vus: Same but VU count changes over time through stages. Use for load tests with warmup and cooldown.
constant-arrival-rate: Sends exactly N requests per second regardless of response time. If responses slow down, k6 adds VUs to maintain the rate (up to maxVUs). Models real internet traffic correctly.
ramping-arrival-rate: Like constant-arrival-rate but rate changes through stages. Use for stress tests where you increase RPS until the service breaks.
1export const options = {
2 scenarios: {
3 // Closed: 20 users, each waits for response before next request
4 closed_model: {
5 executor: 'constant-vus',
6 vus: 20,
7 duration: '5m',
8 },
9 // Open: exactly 100 req/s regardless of response time
10 open_model: {
11 executor: 'constant-arrival-rate',
12 rate: 100,
13 timeUnit: '1s',
14 duration: '5m',
15 preAllocatedVUs: 50,
16 maxVUs: 200,
17 },
18 // Stress: ramp from 10 to 500 req/s to find breaking point
19 ramp_rps: {
20 executor: 'ramping-arrival-rate',
21 startRate: 10,
22 timeUnit: '1s',
23 stages: [
24 { duration: '5m', target: 100 },
25 { duration: '5m', target: 200 },
26 { duration: '5m', target: 500 },
27 { duration: '2m', target: 0 },
28 ],
29 preAllocatedVUs: 100,
30 maxVUs: 1000,
31 },
32 },
33};Running all test types sequentially from a single script is the most efficient CI approach — one k6 invocation, one summary, one set of results.
1import http from 'k6/http';
2import { check, sleep } from 'k6';
3
4export const options = {
5 scenarios: {
6 // 1. SMOKE — validates health (0s-1m)
7 smoke: {
8 executor: 'constant-vus', vus: 2, duration: '1m',
9 startTime: '0s', exec: 'smokeScenario',
10 tags: { type: 'smoke' },
11 },
12 // 2. LOAD — expected peak traffic (2m-17m)
13 load: {
14 executor: 'ramping-vus', startTime: '2m', startVUs: 0,
15 stages: [
16 { duration: '3m', target: 50 },
17 { duration: '8m', target: 50 },
18 { duration: '2m', target: 0 },
19 ],
20 exec: 'loadScenario', tags: { type: 'load' },
21 },
22 // 3. STRESS — find breaking point (18m-29m)
23 stress: {
24 executor: 'ramping-arrival-rate', startTime: '18m',
25 startRate: 50, timeUnit: '1s',
26 stages: [
27 { duration: '3m', target: 100 },
28 { duration: '3m', target: 200 },
29 { duration: '3m', target: 400 },
30 { duration: '2m', target: 0 },
31 ],
32 preAllocatedVUs: 100, maxVUs: 500,
33 exec: 'stressScenario', tags: { type: 'stress' },
34 },
35 // 4. SPIKE — sudden burst (30m-33m)
36 spike: {
37 executor: 'ramping-vus', startTime: '30m', startVUs: 0,
38 stages: [
39 { duration: '10s', target: 300 },
40 { duration: '2m', target: 300 },
41 { duration: '10s', target: 0 },
42 ],
43 exec: 'spikeScenario', tags: { type: 'spike' },
44 },
45 },
46 thresholds: {
47 'http_req_duration{type:load}': ['p(95)<500'],
48 'http_req_failed{type:load}': ['rate<0.001'],
49 'http_req_failed{type:stress}': ['rate<0.15'], // allow higher under stress
50 'http_req_duration{type:spike}': ['p(95)<2000'],
51 'http_req_failed{type:spike}': ['rate<0.05'],
52 'http_req_failed{type:smoke}': ['rate<0.001'],
53 },
54};
55
56export function smokeScenario() {
57 const res = http.get('https://api.example.com/health');
58 check(res, { 'health 200': (r) => r.status === 200 });
59}
60
61export function loadScenario() {
62 const res = http.get('https://api.example.com/courses');
63 check(res, { 'courses 200': (r) => r.status === 200 });
64 sleep(1);
65}
66
67export function stressScenario() {
68 http.get('https://api.example.com/courses?limit=20');
69}
70
71export function spikeScenario() {
72 const res = http.get('https://api.example.com/courses');
73 check(res, { 'not crashed': (r) => r.status < 500 });
74}The wrong executor produces misleading results:
Use ramping-vus/constant-vus when: The number of concurrent users is the meaningful measure. Example: a desktop app with 50 simultaneous logged-in users.
Use constant-arrival-rate/ramping-arrival-rate when: Requests per second is the measure, independent of response time. Example: a public API receiving 1,000 req/s. If your API slows from 100ms to 500ms, real clients still send 1,000 req/s.
Saturation signal in arrival-rate mode: When k6 cannot allocate enough VUs to maintain the target rate, it warns:
1WARN[0180] Insufficient VUs, reached 200 active VUs and cannot initialize moreThis means the system is saturated — requests arrive faster than the server can serve them.
1export const options = {
2 scenarios: {
3 api_stress: {
4 executor: 'ramping-arrival-rate',
5 startRate: 100,
6 timeUnit: '1s',
7 stages: [
8 { duration: '5m', target: 500 },
9 { duration: '5m', target: 1000 },
10 { duration: '5m', target: 2000 }, // find breaking point
11 ],
12 preAllocatedVUs: 200,
13 maxVUs: 2000,
14 },
15 },
16 thresholds: {
17 dropped_iterations: ['count<100'], // <100 dropped iterations allowed
18 },
19};Soak tests run normal load for 4-24 hours, surfacing bugs invisible in short tests: memory leaks (GC cannot keep up after 6 hours), database connection pool exhaustion, log rotation failures (disk fills up), and JWT refresh handling (token expiry after 8 hours).
1// tests/perf/scenarios/soak.js — run overnight
2import http from 'k6/http';
3import { check, sleep } from 'k6';
4
5export const options = {
6 scenarios: {
7 soak: {
8 executor: 'constant-arrival-rate',
9 rate: 100, // normal production load
10 timeUnit: '1s',
11 duration: '8h',
12 preAllocatedVUs: 50,
13 maxVUs: 100,
14 },
15 },
16 thresholds: {
17 http_req_duration: ['p(95)<1000', 'p(99)<3000'],
18 http_req_failed: ['rate<0.001'],
19 },
20};
21
22let iteration = 0;
23
24export default function () {
25 iteration++;
26
27 const res = http.get('https://api.example.com/dashboard', {
28 headers: { Authorization: 'Bearer ' + __ENV.BEARER_TOKEN },
29 });
30 check(res, { 'status 200': (r) => r.status === 200 });
31
32 // Log progress markers for Grafana annotation
33 if (iteration % 1000 === 0) {
34 console.log(`Iteration ${iteration} at ${new Date().toISOString()}`);
35 }
36
37 sleep(0.5);
38}Monitoring soak tests: Connect k6 to InfluxDB and Grafana. Alert when p95 latency exceeds 1.5x baseline in any 10-minute window. A healthy soak test shows flat latency; a failing one shows a slow upward trend.
The most valuable insight from stress/spike tests is not 'it failed' but 'how did it fail and did it recover?'
Graceful degradation (good):
Hard failure (bad):
1// spike.js — measure post-spike recovery
2export const options = {
3 stages: [
4 { duration: '1m', target: 10 }, // baseline
5 { duration: '10s', target: 500 }, // spike
6 { duration: '2m', target: 500 }, // sustain
7 { duration: '10s', target: 10 }, // drop
8 { duration: '3m', target: 10 }, // recovery observation period
9 ],
10 thresholds: {
11 http_req_duration: ['p(95)<1000'],
12 // Use Grafana time-windowed queries to verify p95 < 500ms
13 // in the final 3-minute recovery window
14 },
15};In Grafana, zoom into the 3-minute window after load drops. A well-architected system (circuit breakers, bounded connection pools, auto-scaling) returns to baseline latency within 60-90 seconds.
Production-ready k6 script combining smoke, load, stress, and spike scenarios with per-scenario thresholds, arrival-rate executors, and a handleSummary generating CI-parseable results.
1// tests/perf/full-suite.js
2// Duration: ~40 minutes total
3// Run: k6 run --env BASE_URL=https://staging.api.example.com tests/perf/full-suite.js
4
5import http from 'k6/http';
6import { check, sleep, group } from 'k6';
7import { SharedArray } from 'k6/data';
8import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js';
9
10const users = new SharedArray('users', () => [
11 { email: '[email protected]', password: 'TestPass123!' },
12 { email: '[email protected]', password: 'TestPass123!' },
13]);
14
15export const options = {
16 scenarios: {
17 smoke: {
18 executor: 'constant-vus', vus: 2, duration: '1m',
19 startTime: '0s', exec: 'smokeTest',
20 tags: { scenario: 'smoke' },
21 },
22 load: {
23 executor: 'ramping-vus', startTime: '2m', startVUs: 1,
24 stages: [
25 { duration: '3m', target: 50 },
26 { duration: '10m', target: 50 },
27 { duration: '3m', target: 0 },
28 ],
29 exec: 'loadTest', tags: { scenario: 'load' },
30 },
31 stress: {
32 executor: 'ramping-arrival-rate', startTime: '20m',
33 startRate: 50, timeUnit: '1s',
34 stages: [
35 { duration: '3m', target: 150 },
36 { duration: '3m', target: 300 },
37 { duration: '3m', target: 600 },
38 { duration: '2m', target: 0 },
39 ],
40 preAllocatedVUs: 200, maxVUs: 800,
41 exec: 'stressTest', tags: { scenario: 'stress' },
42 },
43 spike: {
44 executor: 'ramping-vus', startTime: '32m', startVUs: 5,
45 stages: [
46 { duration: '30s', target: 400 },
47 { duration: '2m', target: 400 },
48 { duration: '30s', target: 5 },
49 { duration: '3m', target: 5 }, // recovery window
50 ],
51 exec: 'spikeTest', tags: { scenario: 'spike' },
52 },
53 },
54 thresholds: {
55 'http_req_duration{scenario:load}': ['p(95)<500', 'p(99)<1200'],
56 'http_req_failed{scenario:load}': ['rate<0.001'],
57 'http_req_failed{scenario:stress}': ['rate<0.15'],
58 'http_req_failed{scenario:spike}': ['rate<0.05'],
59 'http_req_duration{scenario:spike}':['p(95)<2000'],
60 'http_req_failed{scenario:smoke}': ['rate<0.001'],
61 'http_req_duration{scenario:smoke}':['p(95)<300'],
62 },
63};
64
65const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
66
67export function smokeTest() {
68 const res = http.get(`${BASE_URL}/api/health`);
69 check(res, { 'health 200': (r) => r.status === 200 });
70 sleep(1);
71}
72
73export function loadTest() {
74 const user = users[Math.floor(Math.random() * users.length)];
75 group('auth + browse', () => {
76 const loginRes = http.post(
77 `${BASE_URL}/api/auth/login`,
78 JSON.stringify(user),
79 { headers: { 'Content-Type': 'application/json' } }
80 );
81 if (loginRes.status !== 200) return;
82 const token = loginRes.json('accessToken');
83 sleep(0.5);
84 const coursesRes = http.get(
85 `${BASE_URL}/api/courses?limit=10`,
86 { headers: { Authorization: `Bearer ${token}` } }
87 );
88 check(coursesRes, { 'courses 200': (r) => r.status === 200 });
89 });
90 sleep(1 + Math.random());
91}
92
93export function stressTest() {
94 const res = http.get(`${BASE_URL}/api/courses?limit=20`);
95 check(res, { 'not 500': (r) => r.status < 500 });
96}
97
98export function spikeTest() {
99 const res = http.get(`${BASE_URL}/api/courses`);
100 check(res, { 'not crashed': (r) => r.status !== 500 });
101}
102
103export function handleSummary(data) {
104 return {
105 'tests/perf/results/full-suite.json': JSON.stringify({
106 timestamp: new Date().toISOString(),
107 metrics: data.metrics,
108 }, null, 2),
109 stdout: textSummary(data, { indent: ' ', enableColors: true }),
110 };
111}This 40-minute suite runs smoke (0s), load (2m), stress (20m), and spike (32m) sequentially. Each scenario uses the appropriate executor: constant-vus for smoke, ramping-vus for load and spike, ramping-arrival-rate for stress. Per-scenario thresholds use tag filtering to apply different SLOs to each test type. The handleSummary hook writes a JSON results file for CI artifact archival. The spike scenario includes a 3-minute recovery observation window after load drops to verify the system returns to baseline.
The next lesson covers k6 thresholds in depth — abortOnFail, delayAbortEval, time-windowed SLO enforcement, and per-endpoint tagging strategies for Grafana dashboards.
Your progress is saved in this browser, on this device only.