Module 4: Load Profiles And Stages

1. Module introduction: giving the load a shape

Overview

Up to here all your measurements had a hidden simplification: the load was constant. In module 2 you launched N virtual users and in module 3 you measured their p95, their throughput, and their error rate. It worked, but it carried an assumption no one said out loud: that traffic is flat, that there are always exactly N users hitting at once, not one more or one less. And that is never true in a real system. Traffic arrives in waves: it rises slowly in the morning, spikes when a campaign goes live, holds during the peak hour, and drops at night. This module gives you the tool to model those waves: the load profile.

A load profile is a shape of the load over time: how many users (or how many requests per second) there are at each instant of the test. In k6 that shape is written with stages —a list of segments that draw a rise (ramp-up), a plateau (steady), and a fall (ramp-down)—. By the end of this module you'll know how to read and write those stages, distinguish the three classic load shapes (constant, ramp, spike) and what each reveals, understand k6's executors (the engines that decide how the load is applied, including load by arrival rate), and above all choose the profile that answers your question. And you'll see it with actually measured numbers: a Python generator that raises and lowers the concurrency in stages against Reservo, showing you how the p95 follows the load like a shadow.

Connection to the module: this lesson is the map. Here you install the big idea —that the load has shape, not just size— and see an executed preview of where we're going. The following lessons develop it in order: lesson 2 argues why constant load isn't enough; lesson 3 opens the anatomy of stages (ramp-up/steady/ramp-down); lesson 4 walks through the three shapes (constant/ramp/spike); lessons 5 and 6 explain the executors (by VUs and by arrival rate); lesson 7 teaches you to choose the profile according to the question; and lesson 8 is the mini-project where you write a staged profile with your own hands. The boundary with the neighboring modules matters: the metrics (how the p95 is computed) were already module 3 —here we reuse them, we don't re-explain them—; and the thresholds (setting a limit that makes the test pass or fail) are module 5, not this one. Here the topic is a single one: the shape of the load.

The analogy: the gym that only trains at one pace

Imagine someone who wants to know if their heart is fit and gets on a treadmill that always goes at the same speed, say eight kilometers per hour, for ten minutes. Getting off they say: "I handled 8 km/h with no problem, I'm fit." And they're not lying: that's real information. But look at all the things that constant-pace test didn't tell them. It didn't tell them how their heart reacts while accelerating from rest to eight km/h —maybe there, on the rise, is where they get dizzy—. It didn't tell them at what speed they stop keeping up —at ten? at twelve? at fifteen?—. It didn't tell them how they recover when the pace drops —does their pulse return to normal in a minute or is it still racing five minutes later?—. A flat pace answers a single question —"can you handle this pace?"— and stays silent about the rise, the limit, and the recovery.

That's why real stress tests —the ones a cardiologist runs— don't go at a constant pace: they follow a protocol with a shape. They start slow, raise the speed and the incline in stages every few minutes, hold the peak, and then lower it to observe the recovery. That shape —rise, hold, lower— is exactly a load profile, and it's precisely what you're going to apply to your API. It's not enough to ask it "can you handle 24 users?"; you're going to ask it "how do you behave while I raise from 0 to 24? at what point do you start to suffer? and when the wave drops, do you recover or do you stay damaged?". An API, like a heart, reveals different things according to the shape of the effort, not just its size.

A load test has two dimensions, not one: the size (how many users) and the shape (how it evolves over time). Module 3 measured the size; this module gives it shape. Raising, holding, and lowering the load reveals things —the startup, the degradation point, the recovery— that a flat load hides completely.

An executed preview: shaped load, actually measured

So the idea doesn't stay abstract, here's the result you'll build in lesson 8, already executed. It's a Python generator that applies a staged profile against the Reservo API: it raises the concurrency (ramp-up), holds it at a peak (steady), and lowers it (ramp-down), measuring the p95 of each stage. The API is the usual canonical one —POST /quote of Focus/basic/3h returns 7500 cents—; the generator hits it with a pool of concurrent workers (the "VUs") that grows and shrinks.

What to expect — the more concurrency, the more requests compete for the server, so the p95 should rise in the ramp-up, stay high at the peak, and fall in the ramp-down. If the degradation is due to load (and not permanent damage), the down stages should mirror the up ones. Real output (run against Reservo on localhost):

$ 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

Read the p95 column from top to bottom and you'll see the wave drawn in numbers: with 4 concurrent users the p95 is 22.55 ms; raising to 12, it jumps to 77.01 ms; at the peak of 24, it reaches 174.63 ms. And then, when the wave drops, the p95 returns: 82.56 ms with 12 again (almost identical to the 77.01 on the way up), and 24.20 ms with 4 (almost the same 22.55 from the start). That symmetry —the fall mirrors the rise— is invaluable information a constant load would never have given you: it tells you the degradation was due to the load, and that the system recovers cleanly when the load eases. A peak that didn't recover (that stayed slow even with 4 users) would be another story —a leak, a resource that isn't released—, and only the ramp-down would have betrayed it.

Notice too that this reuses everything from module 3: the p95 is computed the same, the throughput is still there (the request columns), and the error rate is 0. What's new in this module is the first column: the stages. The load is no longer a number, it's a sequence.

The same in k6: the stages (content)

That staged profile you just saw executed in Python is written in k6 with the stages option. Since k6 isn't installed in this environment (it's a Go binary with its own runtime), its script goes as labeled content: it's correct and faithful to k6's official documentation, but it was not run here. This is what the ramp-up → steady → ramp-down shape looks like in k6:

// 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 }, // ramp-up:   from 0 to 20 VUs in 30 s
    { duration: '1m',  target: 20 }, // steady:    hold 20 VUs for 1 min
    { duration: '30s', target: 0 },  // ramp-down: from 20 to 0 VUs in 30 s
  ],
};

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);
}

Look at the relationship piece by piece. The default function and the http.post with the JSON body are the anatomy of the script you already saw in module 2 —the code each VU runs in a loop—. What's new is export const options = { stages: [...] }: that list of {duration, target} segments is the shape of the load. The first segment takes the VUs from 0 to 20 in 30 seconds (ramp-up); the second holds them at 20 for a minute (steady); the third lowers them to 0 in 30 seconds (ramp-down). It's exactly the same wave the Python generator drew with its stages, only industrialized: k6 interpolates the VUs continuously toward each target, and you read the p95 on the plateau. Lesson 3 breaks down each field; for now keep the correspondence: stages in k6 = the concurrency stages in the Python generator, the same load shape in two languages.

The map: the eight lessons

This module goes from understanding why the load needs a shape to learning to write it to choosing the right one. Each lesson leaves a piece:

LessonWhat it installs
1. Introduction (this one)The load has shape, not just size. Executed and content preview, and the map
2. Constant load isn't enoughTraffic arrives in waves; what a flat load hides; the idea of load shape
3. The stagesThe anatomy of stages: ramp-up, steady, ramp-down, and how they're read
4. The shapesConstant, ramp, and spike: what each reveals
5. Executors: VUsconstant-vus vs ramping-vus; what an executor is
6. constant-arrival-rateLoad by arrival rate (RPS): the open model
7. Choosing the profileQuestion → profile: load, stress, spike, soak
8. Mini-projectYou write a staged profile, in k6 (content) and Python (executed)

The environment rule (reminder) and the boundary

The whole guide's honesty rule is still in force and explains why you sometimes see "real" output and sometimes "here's how it would look." k6 isn't installed, so its .js scripts and its summary go as labeled content —correct, faithful to k6's documentation, but not run here—. By contrast, everything Python is run and cited: the Reservo API and the staged generator, with their measured p95s. The generator is the "executable sibling" of k6's stages: it does the same (apply a load shape and measure), at a smaller scale, so you can touch the numbers. And it never runs git or gh.

On the boundary with the neighboring modules, so you don't mix topics: module 3 was how to measure (the p95, the throughput, the errors) —we take it as known and reuse it—; this module is with what shape to load (the profiles, the stages, the executors); module 5 will be what limit to demand of the result (the thresholds that make it pass or fail). If at any point you find yourself writing thresholds: {...}, remember: that's module 5's lesson. Here we only shape the load.

Common mistakes

Believing "it handles 24 users" is a complete conclusion. What happens: someone runs a constant load of 24 VUs, sees an acceptable p95, and closes the topic. Why it happens: the constant load is the easiest to write, and it gives a number that sounds definitive. How to detect it: if your test never raised or lowered the load, you measured a plateau, not a wave —you don't know how you start up, where you break, or whether you recover—. How to fix it: give the load a shape with stages (or stages in the generator); the plateau is one of the three phases, not the whole story.

Thinking this module measures something new. What happens: someone expects to learn a metric here different from the p95. Why it happens: it's easy to confuse "new load shape" with "new thing to measure." How to detect it: if you're looking for a new formula, you got the wrong module; the p95 formula was module 3. How to fix it: understand that here the novelty is the independent variable (the shape of the load over time), not the dependent one (the metric). We measure the same p95, but along a wave.

Confusing stages with thresholds. What happens: someone puts a pass/fail limit into the profiles conversation and gets tangled up. Why it happens: both live in export const options and sound similar. How to detect it: stages describes the shape of the load (how many VUs and when); thresholds describes the success criterion (what p95 is acceptable). If you're deciding how much load, it's stages (this module); if you're deciding what result passes, it's thresholds (module 5). How to fix it: keep the two questions separate —"what load shape do I apply?" and "what result do I consider a pass?"—.

Exercises

Exercise 1 — Size vs shape. For each sentence, say whether it describes the size of the load or its shape. (a) "I tested with 500 concurrent users." (b) "The load rose from 0 to 500 in two minutes, held for five, and dropped to 0." (c) "I launched a sudden spike of 1000 requests." (d) "I ran 300 fixed VUs for ten minutes."

See solution
  • (a) Size. It gives a concurrency number, but doesn't say how it evolves; it could be constant.
  • (b) Shape. It describes an evolution over time —rise, plateau, fall—: it's a ramp-up/steady/ramp-down profile.
  • (c) Shape. "Sudden" is shape information: a spike (abrupt rise), different from a gradual ramp of the same size.
  • (d) Size (with an implicit shape: constant). "Fixed for ten minutes" is a flat plateau; the size is 300, the shape is constant.

The lesson: size answers how many; shape, when and how. This module is about the shape.

Exercise 2 — Read the wave. Looking at the preview's executed output, answer: (a) What's the p95 at the peak (24 VUs) and at the start (4 VUs)? (b) The p95 of the "ramp-down (mid)" stage with 12 VUs is 82.56 ms; that of "ramp-up (mid)", also with 12 VUs, is 77.01 ms. What does it tell you that they're almost equal? (c) What would you have lost if you had only run a constant load of 24 VUs?

See solution
  • (a) At the peak (24 VUs) the p95 is 174.63 ms; at the start (4 VUs), 22.55 ms. Almost eight times more: the latency grows with the concurrency.
  • (b) That the system recovers cleanly. At the same load level (12 VUs), the p95 is practically the same both going up and going down, which confirms the latency depends on the current load, not on accumulated damage. If on the way down it had stayed much higher, you'd suspect a leak or a resource that isn't released.
  • (c) You'd have gotten only the peak number (174.63 ms) and nothing else: neither how it behaves going up, nor the confirmation that it recovers. A single point instead of the complete wave.

Exercise 3 — Translate the shape. Your boss describes a Monday's traffic like this: "It starts calm at 8, rises hard until 10, holds at peak until 12, and eases off in the afternoon." Describe it as a load profile with its phases (ramp-up/steady/ramp-down) and say what you'd ask each phase.

See solution

It's a three-phase profile:

  • Ramp-up (8:00 → 10:00): the load rises from low to peak. Question: "how does the API behave while demand rises? At what point of the rise does the p95 start to degrade?".
  • Steady (10:00 → 12:00): the load holds at the peak. Question: "in the sustained peak hour, does the p95 stay within acceptable limits, or does it degrade over time?". This is the phase where you read the number you report.
  • Ramp-down (afternoon): the load drops. Question: "when demand eases, does the system recover —does the p95 return to low levels— or does it stay damaged?".

The pattern: each phase of the shape answers a different question (startup, hold, recovery) that a flat load couldn't answer.

Summary and next step

In this lesson you made the module's conceptual leap: a load test has two dimensions, the size (how many users) and the shape (how it evolves over time). Module 3 measured the size with a constant load; this module gives it shape. That shape is the load profile, and its canonical expression is the ramp-up → steady → ramp-down wave: rise, hold, lower. The heart analogy fixes it: a flat pace only tells you whether you handle that pace; a stress test with a protocol reveals the startup, the limit, and the recovery.

You saw it with real numbers: a Python generator that raises the concurrency from 4 to 24 and lowers it, with the p95 drawing the wave (22 → 77 → 175 → 83 → 24 ms) and a symmetry between rise and fall that betrays a clean recovery. And you saw its equivalent in k6: the stages option with its list of {duration, target} segments, presented as labeled content. The correspondence is exact: k6's stages are the Python generator's concurrency stages.

Before moving on you should be able to: explain the difference between the size and the shape of a load; name the three phases of a profile (ramp-up, steady, ramp-down) and what question each one answers; read a p95-per-stage table and interpret the up/down symmetry as recovery; and remember the boundary (metrics were M3, thresholds will be M5, here it's the shape).

What comes next is the argument in depth. In lesson 2 we defend in detail why a constant load isn't enough: what questions a flat plateau answers, which ones it hides, and how the idea of "load shape" is born precisely from the questions constant load leaves unanswered.

Resources