Module 4: Load Profiles And Stages

5. The executors: constant-vus vs ramping-vus

Overview

So far you drew load shapes with stages without asking what engine runs them underneath. That engine has a name in k6: the executor. An executor is the component that decides how the load is applied —how many VUs there are, when they change, how the iterations are launched—. When you write stages, k6 uses a default executor without you noticing; in this lesson we bring it to light and meet the two executors that work with VUs: constant-vus (keeps a fixed number of virtual users for a duration) and ramping-vus (raises and lowers the VUs following some stages —it's exactly what stages does underneath—). You'll also meet the scenarios block, the explicit and more powerful way to configure an executor. As always, k6 goes as labeled content and we run the Python equivalent: a constant load against a staged one, so you see the difference in engine in numbers.

Connection to the module: lesson 4 gave you the shapes; this one tells you what engine produces each shape. It's a level change: you go from "what shape do I want" to "how do I ask k6 for it." The two executors in this lesson work with VUs (concurrent users), which is the model you already know from module 2. In lesson 6 comes the executor that changes the whole question —constant-arrival-rate, which fixes the arrival rate instead of the number of users—, so this lesson is the "by VUs" half of the executors topic, and lesson 6 is the "by arrival rate" half. We don't touch thresholds (module 5): we stay only on how the load is applied.

The analogy: the orchestra conductor

Think of an orchestra. The score says what notes to play —that's your default function, the code each VU runs—. But someone has to decide how many musicians play and when they enter or leave: that's the conductor. A conductor can ask "let all 20 violinists play the whole movement, no changes" —a fixed number of performers—; or can ask "start with 5, let more come in until reaching 20 at the climax, and then let them leave little by little" —a number that rises and falls—. Same score, two ways of conducting, two different sounds.

The executor is your load test's conductor. The score (the default) doesn't change: it's still "hit /quote, verify the status, wait." What the executor decides is the choreography of the VUs: how many there are at each instant and how they change. constant-vus is the conductor who asks for a fixed number of musicians the whole time. ramping-vus is the conductor who has them enter and leave following the score of the stages. Understanding that the executor is a layer separate from the VU code is the key idea: you can run exactly the same default with different executors and get completely different load profiles.

What an executor is and where it lives

In k6, the load configuration lives in export const options. There are two ways to express it:

The short form (shortcut): putting vus + duration, or stages, directly in options. It's what you've seen so far. Underneath, k6 picks an executor for you:

  • If you put vus + duration → it uses constant-vus.
  • If you put stages → it uses ramping-vus.

The explicit form: the scenarios block, where you name the executor and its parameters by hand. It's more verbose but more powerful: it lets you control every detail, run several scenarios at once, and use executors the shortcut doesn't expose (like lesson 6's constant-arrival-rate). A scenario is a named load; its executor field says what engine moves it.

Seeing it with the two VU executors, side by side, makes the pattern clear.

constant-vus: a fixed number of users

The constant-vus executor keeps a constant number of active VUs for a duration. It's the engine of the constant shape (the plateau) from lesson 4. This is how it looks in its short form and its explicit form —labeled content, not run here—:

// CONTENT (not run here). Short form: k6 uses constant-vus underneath.
export const options = {
  vus: 24,
  duration: '1m',
};
// CONTENT (not run here). Explicit form, with scenarios:
export const options = {
  scenarios: {
    steady_load: {
      executor: 'constant-vus',
      vus: 24,
      duration: '1m',
    },
  },
};

Both do the same: 24 fixed VUs hitting the API for a minute. Each VU runs the default in a loop —request, check, sleep, repeat— and the number of VUs never changes: no rise or fall, it's a flat plateau. It's the right shape for a smoke test (few VUs), for verifying an SLO at the expected load, or for a soak (a long duration).

Its executed equivalent is running the Python generator at fixed concurrency. The peak stage of the staged run is exactly that —24 constant workers—, so its number is that of a constant-vus of 24:

What to expect — with 24 fixed VUs, the p95 settles at a stable value; it's the steady-state latency. Real output (the constant stage of 24):

stage               VUs  requests     p95 (ms)  average (ms)
steady    (peak)     24        1775     174.63          63.00

That 174.63 ms is what a constant-vus: 24 would report on its plateau. A single honest number for the stable state, with no rise or fall.

ramping-vus: the VUs that rise and fall

The ramping-vus executor varies the number of VUs following a list of stages —exactly lesson 3's {duration, target} segments—. It's the engine that produces the ramp and spike shapes, and the one stages uses underneath when you put it in the short form. This is how it looks, short and explicit:

// CONTENT (not run here). Short form: k6 uses ramping-vus underneath.
export const options = {
  stages: [
    { duration: '30s', target: 24 }, // ramp-up
    { duration: '1m',  target: 24 }, // steady
    { duration: '30s', target: 0 },  // ramp-down
  ],
};
// CONTENT (not run here). Explicit form, with scenarios:
export const options = {
  scenarios: {
    ramping_load: {
      executor: 'ramping-vus',
      startVUs: 0,               // how many VUs it starts with
      stages: [
        { duration: '30s', target: 24 },
        { duration: '1m',  target: 24 },
        { duration: '30s', target: 0 },
      ],
      gracefulRampDown: '30s',   // let in-progress iterations finish when lowering
    },
  },
};

The explicit form reveals two parameters the shortcut hides: startVUs (how many VUs it starts with; here we set it to 0 to start from zero) and gracefulRampDown (how much time k6 gives for the in-progress iterations to finish orderly when the load lowers, instead of cutting them off abruptly). Otherwise, it's the same three-phase wave: ramping-vus interpolates the VUs toward each target, as you saw in lesson 3.

Its executed equivalent is the complete staged run —the pool of workers that grows and shrinks—:

What to expect — unlike constant-vus (a single number), ramping-vus gives the whole wave: p95 rising on the ramp-up and falling on the ramp-down. Real output:

$ 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

Notice the relationship between the two executors: the steady (peak) row of this run (174.63 ms) is identical to what a constant-vus: 24 would give. It makes sense: on the plateau, ramping-vus is a constant-vus momentarily. The difference is that ramping-vus also gives you the before (the rise) and the after (the fall). A constant-vus is like keeping only the middle row.

When each one

The choice is direct once you understand what each engine produces:

  • constant-vus when your question is about a stable state: a smoke test (few VUs, short duration), verifying an SLO at the known peak load, or a soak (long constant to look for leaks). The shape is a plateau; the engine, constant-vus.
  • ramping-vus when your question involves the evolution: how the system behaves while the load rises (the knee), where it breaks (stress, rising until it yields), or how it reacts to a sudden peak (spike). The shape has a rise and/or a fall; the engine, ramping-vus.

And a practical detail: if you start with the short form (vus+duration or stages) and later need more control —several scenarios at once, startVUs, gracefulRampDown, or mixing executors—, you migrate to the explicit scenarios block. The short form is a comfortable shortcut; scenarios is the full steering wheel.

The executor is the "conductor": it decides how many VUs there are and when, separate from the code each VU runs. constant-vus keeps a fixed number (the plateau); ramping-vus raises and lowers them following stages (the wave). The vus+duration shortcut uses constant-vus; the stages shortcut uses ramping-vus. The scenarios block is the explicit form, with full control.

Common mistakes

Believing stages and ramping-vus are different things. What happens: someone sees a script with stages and another with executor: 'ramping-vus' and thinks they're two separate mechanisms. Why it happens: they look different on the page. How to detect it: if your options has stages in the short form, you're already using ramping-vus —it's the default executor of stages—. How to fix it: understand stages (short) as a shortcut for ramping-vus (explicit); they're the same choreography, one with less typing.

Using ramping-vus for what constant-vus calls for (or vice versa). What happens: someone builds a ramp profile to verify an SLO at a fixed load (putting rise/fall noise into the number), or uses a constant to look for the degradation knee (which the constant can't find). Why it happens: the engine isn't connected to the question. How to detect it: if your question is about a state and you use a ramp, your aggregate number comes out contaminated; if it's about the evolution and you use a constant, you're missing the rise/fall. How to fix it: engine according to the question —stable state → constant-vus; evolution → ramping-vus—.

Forgetting that the default is the same with any executor. What happens: someone thinks changing the profile requires rewriting the VU logic. Why it happens: the code layer (the score) gets mixed with the executor layer (the conductor). How to detect it: if you're touching your default function to change the shape of the load, you got the wrong layer. How to fix it: leave the default alone and change only the executor/stages in options; the separation between "what each VU does" and "how many VUs and when" is exactly what lets you reuse the same script with many profiles.

Exercises

Exercise 1 — Which executor does this shortcut use? For each short-form options, say what executor k6 uses underneath. (a) { vus: 10, duration: '30s' }. (b) { stages: [{duration:'1m', target:50}] }. (c) { vus: 50, duration: '2h' }.

See solution
  • (a) constant-vus. vus + duration → 10 fixed VUs for 30 s (a smoke test).
  • (b) ramping-vus. The presence of stages activates ramping-vus (here a ramp from 0 to 50 in 1 min).
  • (c) constant-vus. vus + duration again, but with a very long duration (2 hours): it's a soak, a sustained constant to look for leaks.

Exercise 2 — Translate short to explicit. Rewrite this shortcut as an explicit scenarios block with the correct executor:

export const options = {
  stages: [
    { duration: '20s', target: 30 },
    { duration: '40s', target: 30 },
    { duration: '20s', target: 0 },
  ],
};
See solution
export const options = {
  scenarios: {
    my_load: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '20s', target: 30 },
        { duration: '40s', target: 30 },
        { duration: '20s', target: 0 },
      ],
      gracefulRampDown: '30s',
    },
  },
};

It's the same profile (ramp-up to 30, steady 40 s, ramp-down to 0), now named (my_load) and with the executor and its extras (startVUs, gracefulRampDown) in plain sight. The stages shortcut produced exactly this.

Exercise 3 — Engine according to the question. For each goal, choose constant-vus or ramping-vus and justify in one sentence. (a) "Confirm Reservo meets p95 < 500 ms with its 24 sustained peak users." (b) "See at what load level the p95 spikes." (c) "Run 40 VUs for 3 hours to detect a memory leak." (d) "Test a sudden peak of 300 users and its recovery."

See solution
  • (a) constant-vus. The question is about the stable state at a known fixed load (24 VUs); a plateau answers it directly.
  • (b) ramping-vus. Finding the knee requires raising the load and traversing levels; a ramp-up.
  • (c) constant-vus. A soak: constant load, but long (3 hours). The engine is constant; the key is the duration.
  • (d) ramping-vus. A spike (baseline → jump → baseline) is a case of ramping-vus with abrupt stages; you need the sudden rise and the fall for the recovery.

Summary and next step

In this lesson you went up a level: from what shape to what engine. An executor is the k6 component that decides how the load is applied —how many VUs and when—, separate from the default each VU runs (the conductor vs the score). You met the two VU-based executors: constant-vus, which keeps a fixed number of users (the plateau; the engine of the smoke, the SLO at fixed load, and the soak), and ramping-vus, which raises and lowers the VUs following stages (the wave; the engine of the ramp and the spike).

You saw that the shortcuts you already used pick the executor for you —vus+durationconstant-vus; stagesramping-vus— and that the scenarios block is the explicit form, with full control (startVUs, gracefulRampDown, several scenarios at once). And you connected it with numbers: the plateau of a constant-vus: 24 (174.63 ms) is identical to the steady row of a ramping-vus, because on the plateau the ramping is a momentary constant; the difference is that the ramping also gives you the before and the after.

Before moving on you should be able to: explain what an executor is and why it's separate from the default; say which executor each shortcut uses; translate a short form into an explicit scenarios; and choose constant-vus or ramping-vus according to whether the question is about a state or an evolution.

What comes next is the executor that changes the question at its root. The two in this lesson fix how many users there are. In lesson 6 we meet constant-arrival-rate, which instead fixes how many requests per second arrive —the arrival rate—, a different (open) model that reveals overloads the by-VU model hides. And you'll see it executed: one rate below and one above Reservo's capacity.

Resources