Module 8: Project Load Test Reservo
3. The smoke→load→stress profile with stages
Overview
The scenario from lesson 2 says what each virtual user does. This profile says how many users there are and when. In this lesson we wrap the quote→book scenario in a three-phase load shape with k6's stages option: smoke (a pinch of load to check the test works), load (the expected normal load, sustained), and stress (pushing beyond normal until you see where the system bends). We write the export const options = { stages: [...] } in k6 (content) and run the executable equivalent in Python: the generator varying the concurrency in stages against Reservo, measuring how the p95 rises with the load. With /quote_cpu as the target, you'll see the p95 climb from ~14 ms in smoke to ~57 ms in load to ~245 ms in stress —the degradation only the peak reveals—.
Connection to the module: this is the capstone's second piece —the shape that wraps lesson 2's scenario—. It fully reuses module 4: the stages option, the three phases ramp-up/steady/ramp-down, and the smoke/load/stress test types. Here we don't re-explain what a stage is or how k6 interpolates the VUs; we use them to draw the capstone's canonical profile. Lesson 4 will put on this profile the thresholds that turn it into a verdict; lesson 5 will read in depth the metrics it produces. Here we focus on the shape and on what each phase reveals.
The three gears of a stress test
Think about a cardiac stress test at the doctor. They don't put you on the treadmill and start at full speed —that wouldn't measure anything useful and would be dangerous—. They go in phases. First, a warm-up: you walk slowly for a few minutes, to confirm the sensors work and that you're fine to start. Then, a sustained pace: you walk at a normal pace, the one you'd do daily, and the doctor measures how your heart responds to that habitual load. And finally, the increasing effort: they raise the speed and the incline beyond normal, pushing until they find your limit —the point where the rhythm spikes or you run out of breath—. Each phase answers a different question: does the test work? do you hold up day to day? where's your ceiling?
The smoke→load→stress profile is that stress test for your API. Smoke is the warm-up: little load, just to confirm the scenario runs and the API responds —if something is broken, you see it here, cheap, before investing in the heavy load—. Load is the sustained pace: the expected normal load, held for a while, to measure the day-to-day performance —the p95 you promise the business under habitual traffic—. Stress is the increasing effort: pushing beyond normal until you see where the p95 spikes —the ceiling, the breaking point, the degradation that in production would be a real traffic peak—. A single load level answers a single question; the three-phase profile answers all three.
The profile in k6 (content)
Here's the capstone's options with its three-phase profile. Remember: labeled content, faithful to k6's documentation, not run here.
// CONTENT (not run here): k6 is not installed.
// Reference: grafana.com/docs/k6 (options → stages).
export const options = {
stages: [
// SMOKE — a pinch of load: does the test run? does the API respond?
{ duration: '30s', target: 5 }, // ramp-up to 5 VUs
{ duration: '1m', target: 5 }, // hold 5 VUs
// LOAD — expected normal load: the day-to-day performance.
{ duration: '1m', target: 20 }, // ramp-up to 20 VUs
{ duration: '3m', target: 20 }, // hold 20 VUs (you read the "normal" p95 here)
// STRESS — beyond normal: where does the system bend?
{ duration: '1m', target: 80 }, // ramp-up to 80 VUs
{ duration: '3m', target: 80 }, // hold 80 VUs (you see the degradation here)
// RAMP-DOWN — orderly descent to observe the recovery.
{ duration: '1m', target: 0 },
],
};
Read it with what you already know from M4, noticing how the three phases emerge from the list of stages:
- Smoke (5 VUs). A soft ramp-up to 5 VUs and a minute holding them. With so little load, the goal isn't to measure performance but to confirm everything works: the scenario runs, the API responds, the checks pass. If the smoke already fails, there's no point spending on the heavy load —you fix it first—.
- Load (20 VUs). Raises to 20 VUs and holds them three minutes. This is the expected normal load —the traffic the API sees on any given day—, and the long plateau lets the metrics stabilize. The p95 you report as "Reservo's normal performance" comes from here (M4: you read it from the steady, not the ramp-up).
- Stress (80 VUs). Pushes to 80 VUs, four times the normal load, and holds them. Here you see what happens when the system is stressed: if the p95 holds, the system has room; if it spikes, you found the degradation point. With
/quote_cpu, the peak is exactly where the p95 crosses the SLO. - Ramp-down (0 VUs). An orderly descent so the in-flight iterations finish and to observe whether the system recovers cleanly. It closes the test.
The sum of the durations (30s + 1m + 1m + 3m + 1m + 3m + 1m = 10m 30s) is how long the test lasts. In a real test the plateaus last minutes, so the steady state is truly steady; in the executed run below we use short stages (seconds) to see it quickly, but the shape —raising the load in phases and measuring each one's p95— is identical.
The profile run in Python
The generator draws this same shape with concurrency stages: it runs the quote→book scenario at 5, then at 20, then at 80 VUs, measuring each stage's p95. It doesn't use minute-long plateaus (to see it quickly), but the mapping is direct: each stage is a phase of the profile, and its number of VUs is the stage's target. We run it against /quote_cpu, the pricing engine whose latency depends on the load:
What to expect — the p95 should rise with the load: low in smoke, moderate in load, spiked in stress. Real output against Reservo:
$ python3.14 loadtest.py http://127.0.0.1:PORT /quote_cpu l3.json
LOAD TEST — quote->book scenario against /quote_cpu
profile: smoke(5) -> load(20) -> stress(80) VUs
--------------------------------------------------------------------------
stage VUs reqs RPS p50 p95 p99 error checks
--------------------------------------------------------------------------
smoke 5 2338 583.3 8.65 14.03 17.09 0.00% 100.00%
load 20 2918 578.6 34.93 56.96 63.91 0.00% 100.00%
stress 80 3522 583.6 136.75 245.17 261.04 0.00% 100.00%
--------------------------------------------------------------------------
Map each row to its phase and read the story the p95 tells:
- Smoke (5 VUs): p95 = 14.03 ms. Little load, low latency. The test runs and the API responds correctly (checks 100%). All in order to continue.
- Load (20 VUs): p95 = 56.96 ms. The normal load quadruples the VUs and the p95 rises to ~57 ms —still comfortable under a 200 ms SLO—. This would be Reservo's "day-to-day p95" with this pricing engine.
- Stress (80 VUs): p95 = 245.17 ms. The peak quadruples the VUs again, and here the p95 spikes above 200 ms. The CPU work serialized by the GIL doesn't scale with concurrency: when 80 clients ask for a price at once, the compute queue grows and the latency breaks. This is what the stress exists to reveal —a degradation that smoke and load, with their lower load, don't show—.
Notice two more things. The RPS holds almost constant (~580 req/s) across the three stages: the server processes approximately the same amount of work per second (it's CPU-saturated), so raising VUs doesn't increase the throughput —it only lengthens the queue, and that queue is what spikes the p95—. And the error rate stays at 0%: the system doesn't fail, it only gets slow. Latency and availability are different things; the stress broke one (the latency) without touching the other (the errors). That's why the three instruments are read together (M3), and why the capstone puts a threshold on each one (lesson 4).
Common mistakes
Skipping the smoke and starting on the heavy load. What happens: the stress stage is launched directly and the test blows up —but you don't know whether it's from a scenario bug or because the system really doesn't hold up—. Why it happens: the smoke looks like a trivial step you can omit. How to detect it: if your profile starts at dozens of VUs without a minimal prior phase, you have no way to distinguish a test error from a system limit. How to fix it: always start with smoke (few VUs). If the smoke passes, you know the test and the API work, and any later failure is from load, not from setup. The smoke is cheap and saves you debugging blindly under heavy load.
Reading the p95 of the ramp-up or the aggregate as the peak's. What happens: a p95 measured during the ramp-up (when the load hasn't yet reached the target) or the average of the whole test is taken, and reported as "the p95 under stress." Why it happens: they're the most at-hand numbers. How to detect it: if your "peak" p95 mixes lower-load phases, it comes out artificially low. How to fix it: report the p95 of each phase's plateau separately —as the generator does, with one row per stage—. The stress p95 is that of the 80 sustained VUs, not that of the ramp-up or the whole test (M4).
Interpreting the flat RPS as "nothing's happening." What happens: it's seen that the RPS barely changes between load and stress and it's concluded that the system holds up the same. Why it happens: more VUs = more RPS is expected. How to detect it: if the RPS stalls while the p95 rises, the system is saturated: it doesn't process more work, it only accumulates queue. How to fix it: read the RPS and the p95 together. A flat RPS with rising p95 is the signature of saturation —the system reached its throughput ceiling and the extra VUs only wait in line—. That's exactly what the stress seeks to find.
Exercises
Exercise 1 — Name the phase. For each stage, say which phase (smoke / load / stress / ramp-down) it belongs to and what question it answers: (a) { duration: '3m', target: 20 } with the expected normal load at 20 VUs. (b) { duration: '1m', target: 5 } at the start of the test. (c) { duration: '3m', target: 80 } at four times the normal load. (d) { duration: '1m', target: 0 } at the end.
See solution
- (a) Load (sustained normal load). It answers: what's the day-to-day p95 under the expected traffic?
- (b) Smoke (a pinch of load at the start). It answers: does the test run? does the API respond? is it worth continuing to the heavy load?
- (c) Stress (beyond normal). It answers: where does the system bend? does the p95 hold or spike under the peak?
- (d) Ramp-down (descent to 0). It answers: does the system recover cleanly when the load drops?
Exercise 2 — Predict the shape of the p95. With /quote (the fast endpoint, without CPU work) instead of /quote_cpu, how would you expect the p95 column to look across the three stages: would it rise just as hard, rise a little, or not change? Justify.
See solution
It would rise a little. /quote only computes a price (a multiplication and an integer division) and responds: it has no CPU work for the GIL to serialize, so it serves the concurrent requests almost in parallel. Going from 5 to 20 to 80 VUs, the p95 would grow —there's more contention for the server and the local network— but from milliseconds to tens of milliseconds, far from the 200 ms SLO. The shape would be the same (p95 rising with the load) but the magnitude much smaller: the system has plenty of room. It's exactly the capstone's "green" case, and it contrasts with /quote_cpu, whose p95 does cross the threshold under stress. (In the real run, /quote gave p95 ~1 → ~6 → ~26 ms across the three stages.)
Exercise 3 — Design a spike profile. This lesson's profile raises the load in sustained steps. You also want to test a spike: the normal load (20 VUs) interrupted by an abrupt jump to 100 VUs for 10 seconds, and back to 20. Write that spike's stages and say what question it answers that the stepped profile doesn't.
See solution
stages: [
{ duration: '30s', target: 20 }, // normal load
{ duration: '1m', target: 20 }, // hold the normal
{ duration: '5s', target: 100 }, // SPIKE: abrupt jump to 100 VUs
{ duration: '10s', target: 100 }, // hold the brief peak
{ duration: '5s', target: 20 }, // return abruptly to normal
{ duration: '1m', target: 20 }, // observe the recovery
]
It answers a question the stepped profile doesn't: how does the system react to a sudden increase in load (an abrupt traffic peak: a campaign, a viral mention, a Monday morning)? The stepped one rises gradually and lets it warm up; the spike hits all at once, without warning, and measures whether the system absorbs the burst or collapses —and, on the return to 20 VUs, whether it recovers quickly or stays degraded—. They're different degradations: a stress test finds the ceiling with increasing load; a spike finds the fragility in the face of surprise (M4).
Summary and next step
In this lesson you wrapped the scenario in its load shape: the smoke→load→stress profile with k6's stages option. Each phase answers a question —smoke: does the test work?; load: what's the day-to-day p95?; stress: where does the system bend?—, like the three gears of a stress test. You wrote it in k6 (content) and ran it for real in Python against /quote_cpu, seeing the p95 climb with the load: 14.03 → 56.96 → 245.17 ms. The peak revealed the degradation smoke and load don't show, with two key signals: the flat RPS (~580 req/s, the CPU-saturated system) and the error rate at 0% (it gets slow, it doesn't fail) —latency and availability are different things—.
You fully reused module 4 (stages, the three phases, the test types). Before moving on you should be able to: read a stages and name its phases; explain what the stress reveals that load doesn't; and read the RPS and the p95 together to recognize the saturation. What comes next, in lesson 4, is to put a verdict on this profile: the thresholds tied to the SLO (M5) that turn that 245 ms p95 into a FAIL that blocks the deploy —and where the threshold number comes from—.
Resources
- k6 —
stagesoption — the exact reference for the list of{duration, target}stages with which the capstone's profile is drawn; the source of this lesson's k6 content. - k6 — Test types (smoke, load, stress, spike, soak) — the official guide to what question each test type answers and how its profile is drawn; the foundation of the three phases.
- k6 — Stress testing — how the stress phase (beyond the normal load) is specifically designed to find the breaking point; the detail of the stage that breaks the p95.
concurrent.futures.ThreadPoolExecutor— Python documentation — the thread pool the generator uses to raise and lower the concurrency in stages to draw the shape; the engine of the executed run.