Composing average, spike, and soak workload mixes in one script with scenarios executors, separating traffic per endpoint via groups, and measuring application-specific things with the Trend and Rate custom metrics from k6/metrics.

In episode 8 you channeled test data into scripts — the resulting load is now realistic from the input side. But note: all examples so far use a single load model. vus: 10 for one minute answers the question "how does the server handle stable load?" — but doesn't answer "how does the server handle a fivefold spike?" or "is there a memory leak after half an hour?".
This episode closes the foundation by bringing all three together. We'll build complex scenarios and multi-endpoint workloads: understand the three major load models (average, spike, soak), run several models in one script via the scenarios option, separate traffic per endpoint with group(), and measure things specific to your business with the Trend and Rate custom metrics. This is the episode closest to the real work of a professional load tester.
Before writing configuration, understand why these three models exist. Each answers a different business question, and ignoring one of them means letting a certain class of failure slip through:
| Model | Goal | Load Shape | Question Answered |
|---|---|---|---|
| Average / Load | Normal peak conditions | Ramp up, hold, ramp down | How is performance at the busiest daily traffic? |
| Spike | Sudden surge | Drastic rise within seconds | Does the server survive a traffic explosion? |
| Soak | Long-term endurance | Constant, long duration (hours) | Is there degradation from memory or connection leaks? |
The three aren't substitutes for one another — they complement each other. Mature teams run average on every release, spike before big events, and soak periodically to validate long-term stability. In this episode 9 you'll run all three in one command.
The key to "one script, many loads" is the scenarios option. It lets you define several scenarios running simultaneously, each with its own executor, load, and execution function:
export const options = {
scenarios: {
smoke: {
executor: "shared-iterations",
vus: 1,
iterations: 10,
exec: "checkoutFlow",
},
load: {
executor: "ramping-vus",
startTime: "30s",
stages: [
{ duration: "1m", target: 80 },
{ duration: "3m", target: 80 },
{ duration: "1m", target: 0 },
],
exec: "checkoutFlow",
},
soak: {
executor: "constant-vus",
startTime: "1m",
vus: 40,
duration: "15m",
exec: "checkoutFlow",
},
},
};Read the structure: each scenario has an executor (the load mechanism), exec (the function executed), and an optional startTime (when it starts relative to the beginning of the test). Let's map these to the workload models:
shared-iterations — the total number of iterations is divided evenly among VUs. Suited for smoke tests: one VU and a few iterations are enough to validate functional correctness before the big load arrives.ramping-vus — VUs ramp up and down following stages. The heart of average load and spike (with stages that raise the target very quickly).constant-vus — a fixed number of VUs for a given duration. The natural shape of a soak test.There are two additional executors you should know: constant-arrival-rate and ramping-arrival-rate — based on throughput (iterations per second) instead of the number of VUs. You use them when the target is requests per second that must be maintained, not just a number of users.
One golden rule when using scenarios: top-level vus, duration, and stages must not be used — everything lives inside each scenario. If you still use them, k6 ignores or rejects them, and the results are confusing.
A multi-endpoint script means the http_req_duration result merges all URLs. To see per-endpoint performance, k6 already tags each request with url. But for a separation that's more meaningful for business — "purchase flow" vs "search flow" — use group():
import http from "k6/http";
import { check, group } from "k6";
export function checkoutFlow() {
group("browse", () => {
const res = http.get("https://api.example.com/products");
check(res, { "daftar produk 200": (r) => r.status === 200 });
});
group("beli", () => {
const res = http.post("https://api.example.com/orders", "{}", {
headers: { "Content-Type": "application/json" },
});
check(res, { "order dibuat": (r) => r.status === 201 });
});
}group() wraps a block of requests with a label that appears in the summary — total duration, request count, and checks for each group are reported separately. This turns the output from a "list of URLs" into a "business story": how long does the browse phase take? how long does the buy phase take? For non-technical teams, this difference is very valuable.
k6's built-in metrics measure HTTP in general. But your business has its own metrics — payment duration, failed order ratio, transactions per second. k6/metrics provides four types for this: Counter (total count), Gauge (latest value), Trend (value distribution, like http_req_duration), and Rate (true-false ratio). The two most used: Trend and Rate.
import http from "k6/http";
import { check } from "k6";
import { Trend, Rate } from "k6/metrics";
const paymentLatency = new Trend("payment_duration", true);
const paymentErrors = new Rate("payment_errors");
export const options = {
thresholds: {
payment_duration: ["p(95)<700"],
payment_errors: ["rate<0.01"],
},
};
export default function () {
const res = http.post("https://api.example.com/payments", "{}", {
headers: { "Content-Type": "application/json" },
});
paymentLatency.add(res.timings.duration);
paymentErrors.add(res.status !== 200);
check(res, { "pembayaran diterima": (r) => r.status === 201 });
}Let's dissect the three most important lines:
new Trend("payment_duration", true) — the second argument true tells k6 this is a time metric, so it's reported in milliseconds with the same format as http_req_duration. Values are added with .add(ms).new Rate("payment_errors") — accepts booleans: add(res.status !== 200) increments the numerator only when the condition is true. The result is an error ratio as a decimal."p(95)<700" and "rate<0.01" enforce standards on things specific to the business, not just raw HTTP.Notice the reasoning behind it: http_req_duration measures all requests in the script, a mix of browse and buy. payment_duration isolates only the payment phase. Custom metrics are the way to turn raw data into actionable signals.
Tie all the layers together — scenarios, groups, and custom metrics — into one professional script:
import http from "k6/http";
import { check, group } from "k6";
import { Trend, Rate } from "k6/metrics";
const paymentLatency = new Trend("payment_duration", true);
const paymentErrors = new Rate("payment_errors");
export const options = {
scenarios: {
smoke: {
executor: "shared-iterations",
vus: 1,
iterations: 5,
exec: "checkoutFlow",
},
load: {
executor: "ramping-vus",
startTime: "30s",
stages: [
{ duration: "1m", target: 80 },
{ duration: "3m", target: 80 },
{ duration: "1m", target: 0 },
],
exec: "checkoutFlow",
},
soak: {
executor: "constant-vus",
startTime: "1m",
vus: 40,
duration: "15m",
exec: "checkoutFlow",
},
},
thresholds: {
http_req_duration: ["p(95)<500"],
payment_duration: ["p(95)<700"],
payment_errors: ["rate<0.01"],
},
};
export function checkoutFlow() {
group("browse", () => {
check(http.get("https://api.example.com/products"), {
"daftar produk 200": (r) => r.status === 200,
});
});
group("beli", () => {
const pay = http.post("https://api.example.com/payments", "{}", {
headers: { "Content-Type": "application/json" },
});
paymentLatency.add(pay.timings.duration);
paymentErrors.add(pay.status !== 201);
check(pay, { "pembayaran diterima": (r) => r.status === 201 });
});
}Run it with k6 run script.js and observe what happens: smoke runs first validating the flow, then load crawls up pressing the server, and soak holds stable load for 15 minutes — all three firing at the same checkoutFlow function. One script, three business questions, three separate answers.
The three scenarios share the same function, so make sure they don't disturb each other's state — the per-VU pattern from episode 5 (__VU) guarantees each VU has its own session. If one scenario needs a different flow, just define a second exec function.
vus or duration next to scenarios. Top-level configuration and scenarios must not mix — load must live inside the scenarios.http_req_duration mixes all URLs. Separate with group() and custom metrics.startTime for parallel scenarios. Without a stagger, all scenarios rush in from the first second and the results are unreadable.In this episode 9 you've assembled the complete foundation of professional load testing:
scenarios option: many executors in one script, with shared-iterations for smoke, ramping-vus for load, and constant-vus for soak.group(): separating traffic per business flow so results read as a story, not a list of URLs.Trend and Rate from k6/metrics to measure and bind standards to things specific to your application.Congratulations — you now write load tests that don't just send requests, but tell a story about the system. However, there's one layer we've so far treated as a black box: the network itself. In episode 10 we open that layer — Networking, TLS, and Protocol Support: controlling TLS verification, connection behavior, redirects, and user agents, plus testing WebSocket. Because under high load, what gives up first is often not your application code, but its network layer. See you in episode 10!