Module 4: Load Profiles And Stages

3. The stages: ramp-up, steady, ramp-down

Overview

You already know why the load needs a shape. Now you learn to write it. k6's tool for drawing a load profile is the stages option: a list of segments, each a {duration, target} pair, that tells k6 how many virtual users (VUs) should be active and when. With that list, k6 goes raising and lowering the number of VUs continuously, drawing whatever wave you want. In this lesson we take that list apart piece by piece: what each field means, how k6 interpolates the VUs to reach each target, and —most important— the three phases that almost always make up a profile: ramp-up (the rise), steady (the plateau where you read the result), and ramp-down (the fall where you confirm the recovery). Since k6 isn't installed, its script goes as labeled content; and as always, we run the Python equivalent against Reservo so you see the three phases in real numbers.

Connection to the module: lesson 2 convinced you that the shape matters; this one gives you the syntax to express it. It's the module's most "anatomy" lesson, the one that lets you read any stages you find. It reuses the k6 script anatomy from module 2 (the default function, http.post, check, sleep) —you already know that— and adds the only new piece: export const options = { stages: [...] }. The concrete shapes you can draw with stages (constant, ramp, spike) are lesson 4; the executors underneath (that stages uses ramping-vus by default) are lesson 5. Here we focus on the mechanics of the segment list and its three phases.

The analogy: the cruise control recipe

Imagine a car's cruise control, but one programmable by segments. Instead of setting a single speed, you give it a recipe: "for the next 30 seconds, rise gradually to 100 km/h; hold 100 for a minute; in the last 30 seconds, drop gradually to 0". The cruise control doesn't jump to each speed all at once: it interpolates, accelerates or brakes smoothly to reach each segment's target right when its duration ends. The recipe has three clear parts —accelerate, cruise, brake— and each segment is defined by two things: how long it lasts and what speed you want to reach at the end.

k6's stages are exactly that recipe, but the "speed" is the number of VUs (concurrent users). Each segment is a {duration, target}: duration is how long the segment lasts, target is the number of VUs you want to reach at the end of that segment. And k6, like the cruise control, interpolates: it doesn't jump all at once, but adjusts the number of VUs continuously from where it was to the target, spread over the duration. That word —interpolate— is the key to reading a stages well: the target isn't "how many VUs during the segment," it's "how many VUs at the end of the segment."

The anatomy of stages (content)

Here's the canonical three-phase profile in k6. Remember: labeled content, correct and faithful to k6's documentation, not run here.

// CONTENT (not run here): k6 is not installed.
// Reference: grafana.com/docs/k6 (options → stages).
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 20 }, // segment 1 — ramp-up:   from 0 to 20 VUs
    { duration: '1m',  target: 20 }, // segment 2 — steady:    stay at 20 VUs
    { duration: '30s', target: 0 },  // segment 3 — ramp-down: from 20 to 0 VUs
  ],
};

export default function () {
  const url = 'http://127.0.0.1:8000/quote';
  const payload = JSON.stringify({ room: 'Focus', tier: 'basic', hours: 3 });
  const params = { headers: { 'Content-Type': 'application/json' } };

  const res = http.post(url, payload, params);
  check(res, { 'status 200': (r) => r.status === 200 });
  sleep(1);
}

Read the stages list from top to bottom, remembering that each target is "how many VUs to reach at the end of the segment," starting from where the previous segment left you:

  • Segment 1 — { duration: '30s', target: 20 }. At the start of the test there are 0 VUs. This segment takes the VUs from 0 to 20 over 30 seconds, interpolating: at 15 seconds there'll be about 10, at exactly 30 there'll be 20. It's the ramp-up, the rise.
  • Segment 2 — { duration: '1m', target: 20 }. We come from 20 VUs, and the target is again 20. Since the starting point and the target coincide, k6 doesn't raise or lower: it holds 20 VUs for a minute. It's the steady, the plateau.
  • Segment 3 — { duration: '30s', target: 0 }. We come from 20, and the target is 0. This segment lowers the VUs from 20 to 0 in 30 seconds. It's the ramp-down, the fall.

Notice the plateau trick: a segment is "flat" (steady) when its target equals the previous segment's target. There's no "steady" keyword in k6; the plateau emerges from putting two consecutive segments with the same target. And notice the total duration: 30s + 1m + 30s = 2 minutes. The sum of the durations is how long the whole test lasts.

The three phases and what each one does

A ramp-up → steady → ramp-down profile isn't an arbitrary convention: each phase has a job, and understanding that job is what lets you read the results without getting it wrong.

Ramp-up (the rise): warm up and traverse the levels. The gradual rise does two things. First, it warms up the system: in the real world there are caches that fill, connections that open, code that compiles just in time (JIT), pools that initialize. Starting straight at the peak measures that cold start, which doesn't represent real traffic (which rises gradually). Second, it traverses the intermediate levels: passing through 5, 10, 15, 20 VUs, the ramp-up lets you see at which level the p95 starts to bend —the degradation knee—. That's why, normally, the ramp-up isn't where you read your final number: it's a transition, useful for observing the rise but contaminated by the warm-up.

Steady (the plateau): the stable state where you read the result. Here the load is constant at the target, the system is already warm and stabilized, and the metrics settle. This is the phase you report the p95 from. When someone says "the p95 under 20 users is 175 ms," they mean the p95 measured on the plateau, not during the rise. The plateau must last long enough for the metrics to stabilize —a few minutes in a real test, not a few seconds—; if it's too short, the number still carries the warm-up noise.

Ramp-down (the fall): confirm the recovery. The gradual fall returns the system to rest and lets you observe the recovery. You compare the fall's latency with the rise's at the same load level: if they return to similar values, the system recovers cleanly; if on the fall it stays much higher, something didn't get released (a leak, a queue that doesn't drain). Many tests neglect the ramp-down —"the peak already passed"—, and with that they miss exactly the leak signal. In k6, moreover, the ramp-down lets the in-progress iterations finish orderly instead of cutting them off abruptly.

The executed equivalent: the three phases in Python

The Python generator draws this same shape with concurrency stages: it raises the number of workers (VUs) to a peak and lowers it, measuring each stage's p95. It doesn't interpolate continuously like k6 —it uses discrete levels, which are easier to read—, but the mapping is direct: each stage is a stages segment, and its number of VUs is the target.

What to expect — the p95 should rise throughout the ramp-up, be maximal at the steady, and fall on the ramp-down mirroring the rise. Real output against Reservo:

$ python3.14 staged_load.py http://127.0.0.1:PORT
staged profile against http://127.0.0.1:PORT/quote  (Focus/basic/3h -> 7500)
stage               VUs  requests     p95 (ms)  average (ms)
--------------------------------------------------------------
ramp-up   (warm)      4        1028      22.55          12.17
ramp-up   (mid)      12        1208      77.01          33.18
steady    (peak)     24        1775     174.63          63.00
ramp-down (mid)      12        1187      82.56          33.65
ramp-down (cool)      4        1054      24.20          11.85
--------------------------------------------------------------
errors: 0

Map each row to its phase:

  • The first two rows (4 and 12 VUs) are the ramp-up: the load rises and the p95 with it (22.55 → 77.01). Here you observe the rise, but you don't report: it's a transition.
  • The middle row (24 VUs) is the steady: the sustained peak, 174.63 ms. This is the number you'd report as "p95 at the peak load."
  • The last two rows (12 and 4 VUs on the way back) are the ramp-down: the load drops and the p95 returns (82.56 → 24.20). Here you confirm the recovery by comparing with the rise: 82.56 on the fall against 77.01 on the rise at 12 VUs; 24.20 on the fall against 22.55 on the rise at 4 VUs. Almost identical: a clean recovery.

That's how k6's stages (content) and the generator's stages (executed) correspond: the same three-phase wave, written in two languages. The target of each k6 segment is the number of VUs of each generator stage.

What the k6 summary looks like (content)

When you run a script with stages, the k6 summary reports the metrics aggregated over the whole test (rise, plateau, and fall together), plus the VU range. This is what it looks like —labeled content, faithful to the shape of the k6 summary, not run here—:

// CONTENT (not run here): shape of the k6 summary. Ref: grafana.com/docs/k6
     scenarios: (100.00%) 1 scenario, 20 max VUs, 2m0s max duration
              * default: Up to 20 looping VUs for 2m0s over 3 stages

     http_req_duration...: avg=110ms min=8ms med=95ms max=480ms p(90)=180ms p(95)=210ms
     http_req_failed.....: 0.00%   ✓ 0     ✗ 2400
     http_reqs...........: 2400    20/s
     vus.................: 1       min=0   max=20
     vus_max.............: 20      min=20  max=20

Two things matter here. First, the vus line shows max=20: k6 confirms the shape reached the peak of 20 VUs you asked for in the stages. Second —and it's a classic trap—: the p(95) of that summary (210ms) is the p95 of the whole test mixed together, including the rise and the fall, not just the plateau's. Since during the rise and the fall the load was lower, that aggregate p95 tends to be somewhat lower than the plateau's real p95. If you want the clean steady-state p95, in k6 you isolate the plateau (with a per-scenario threshold, or looking at the metric over time with --out, which is module 7 material). That's why the Python generator reports the p95 per stage: so you see each phase separately, without the rise and fall diluting the peak's number.

Common mistakes

Reading target as "VUs during the segment" instead of "VUs at the end of the segment." What happens: someone sees { duration: '1m', target: 20 } as a second segment and thinks "during that minute there are 20 VUs," without realizing that's only true because the previous segment also ended at 20. Why it happens: the word target is ambiguous if you don't remember that k6 interpolates. How to detect it: if a segment has a target different from the previous one, inside that segment the number of VUs is changing, not fixed. How to fix it: read each segment as "from the current VUs, reach target in duration"; a segment is flat only if its target equals the previous one's.

Reporting the aggregate p95 of the whole test as the peak's p95. What happens: someone takes the p(95) from the k6 summary (which mixes rise, plateau, and fall) and presents it as "the p95 at peak load." Why it happens: it's the most visible number in the summary. How to detect it: if your profile has a ramp-up and ramp-down, the aggregate p95 includes low-load instants that cheapen it; it's not the steady state's. How to fix it: isolate the plateau —the generator's per-stage p95, or a per-scenario threshold/export in k6— to report the real peak.

Setting a plateau that's too short. What happens: a stages with a plateau of a few seconds and that p95 gets reported. Why it happens: you want the test to finish quickly. How to detect it: if the plateau doesn't last long enough for the system to warm up and the metrics to stabilize, the p95 still carries startup noise. How to fix it: give the plateau a reasonable duration (minutes in a real test) so the steady state is really stable; a fast rise is fine, a rushed plateau isn't.

Exercises

Exercise 1 — Translate the stages into words. Given this profile, describe what each segment does and how long the test lasts in total:

stages: [
  { duration: '10s', target: 50 },
  { duration: '30s', target: 50 },
  { duration: '10s', target: 100 },
  { duration: '20s', target: 0 },
]
See solution
  • Segment 1 (10s, 50): ramp-up from 0 to 50 VUs in 10 seconds.
  • Segment 2 (30s, 50): steady at 50 VUs for 30 seconds (target equal to the previous → plateau).
  • Segment 3 (10s, 100): ramp-up again, from 50 to 100 VUs in 10 seconds (a second rise, more abrupt).
  • Segment 4 (20s, 0): ramp-down from 100 to 0 VUs in 20 seconds.

Total duration: 10 + 30 + 10 + 20 = 70 seconds. It's a two-step profile (rises to 50, holds, rises to 100, falls): useful for seeing the behavior at 50 and then pushing to 100.

Exercise 2 — Which is the plateau? For each stages, say whether it has a steady phase (plateau) and at what VUs. (a) [{duration:'30s',target:20},{duration:'30s',target:0}]. (b) [{duration:'20s',target:30},{duration:'1m',target:30},{duration:'20s',target:0}]. (c) [{duration:'1m',target:100}].

See solution
  • (a) No plateau. It rises from 0 to 20 and falls from 20 to 0; it's a triangle (ramp up, ramp down) with no flat segment. It never holds a load.
  • (b) Yes, plateau at 30 VUs for 1 minute (the second segment has the same target as the first). It's the canonical ramp-up/steady/ramp-down profile.
  • (c) It's all plateau... with an implicit rise. A single segment that goes from 0 to 100 in 1 minute: it's a continuous ramp upward, with no explicit flat segment. If you wanted to hold 100, you'd need a second segment {duration:'...', target:100}.

Exercise 3 — Design the three phases. You want to test Reservo with a peak load of 40 VUs: a half-minute rise, a two-minute plateau (where you'll read the p95), and a half-minute fall. Write the stages and say which segment you'd report the number from.

See solution
stages: [
  { duration: '30s', target: 40 }, // ramp-up:   0 -> 40 VUs
  { duration: '2m',  target: 40 }, // steady:    hold 40 VUs (you read here)
  { duration: '30s', target: 0 },  // ramp-down: 40 -> 0 VUs
]

You'd report the p95 of the second segment (the 2-minute plateau at 40 VUs): it's the steady state, already warm and stabilized. You look at the rise and the fall for the knee and the recovery, but the "official" performance number at the peak load comes from the plateau. Total duration: 3 minutes.

Summary and next step

In this lesson you learned to write the shape of the load. k6's stages option is a list of {duration, target} segments, where duration is how long the segment lasts and target is the number of VUs to reach at the end of it —starting from where the previous segment left you—. k6 interpolates the VUs continuously toward each target, like a cruise control programmable by segments. A plateau (steady) isn't a keyword: it emerges from putting two consecutive segments with the same target.

Above all, you understood the three phases and their job: the ramp-up warms up the system and traverses the intermediate levels (you observe the rise, but don't report from there); the steady is the stable state where you read the p95 you report; and the ramp-down confirms the recovery by comparing the fall with the rise at the same level. You saw it executed in Python —the per-stage p95 drawing the wave 22 → 77 → 175 → 83 → 24 ms— and as content in k6, with the warning that the aggregate summary's p(95) isn't the peak's (it mixes rise and fall).

Before moving on you should be able to: read a stages and say what each segment does and how long the test lasts; identify which is the plateau phase; explain why you report the steady's p95 and not the ramp-up's or the aggregate; and map each phase to its row in the generator's output.

What comes next is expanding the repertoire of shapes. In lesson 4 we walk through the three canonical shapes you can draw with stages —constant, gradual ramp, and spike (abrupt rise)— and what each reveals, with a spike actually executed against Reservo so you see the difference between rising slowly and rising all at once.

Resources