Module 2: The K6 Script And Virtual Users

2. The `default` function and the VU loop

Overview

Every k6 load test revolves around a single function: default. It's the virtual user's script —the code a VU runs top to bottom, and on finishing, runs again from the beginning, over and over, until time runs out—. Understanding this function and the loop around it is understanding 80% of k6's execution model. Everything else (requests, checks, pauses) lives inside this function; and the number of users and the duration live outside, in options. In this lesson we open the central piece.

The idea to fix is counterintuitive at first: you write what happens once, and k6 repeats it. There's no for loop in your code. You write "quote, verify, wait" —one lap— and k6 takes care of repeating that lap in each VU, in each iteration, throughout the whole run. That complete lap through the script has a name you'll use all guide long: an iteration. A VU that runs thirty seconds does many iterations; counting iterations is counting how many times the full script was run through.

Connection to the module: lesson 1 gave you the map —script, cast, VUs—; this one opens the first piece, the script itself. Here, for the first time in the module, we'll put a VU to run for real: a Python thread that repeats a script in a loop against the Reservo API and counts its real iterations. The k6 script is shown as content; the Python generator is run. Everything labeled as Python output was measured in this environment with Python 3.14.0 against the canonical API. Lessons 3 to 5 will fill the script with content (requests, checks, pauses); here we focus on the shape of the loop.

The factory lathe

Think of it this way. In a factory there's a worker at their workstation. Their task is described on an instruction card: "take a part from the belt, fit it in the lathe, machine it, drop it in the output bin." The worker doesn't read "do this 500 times": they read one lap of the task, run it, and when they finish, they look at the belt and start again with the next part. They repeat the same card all shift. If the boss wants more output, they don't rewrite the card: they put more workers at more stations, all with the same card, working in parallel.

The default function is that instruction card: it describes one lap of a user's work. The VU is the worker: it runs the card, finishes, and starts again without anyone telling it to —that automatic "starts again" is the VU loop—. And each complete lap of the card is an iteration, like each finished part. If you want more load, you don't rewrite default: you put more VUs (more workers) in options. You design the card; k6 manages the shift.

The default function describes a single lap of a user's work. The VU runs it, finishes, and starts again automatically: that's the VU loop. Each complete lap is an iteration. You don't write the loop; you write what happens once and k6 repeats it.

The default function in k6 (content)

Here's a minimal script, reduced to the essentials to see the loop without distractions. It quotes at /quote and makes a pause —nothing more—:

// quote_loop.js — the minimal script of a VU.
// SHOWN AS CONTENT: k6 is not installed in this environment.
import http from 'k6/http';
import { sleep } from 'k6';

const BASE_URL = 'http://localhost:8000';

// This function is the script. A VU runs it, finishes, and RUNS it again.
export default function () {
  const payload = JSON.stringify({ room: 'Focus', tier: 'basic', hours: 3 });
  const params = { headers: { 'Content-Type': 'application/json' } };

  // One lap of the script = one iteration.
  http.post(`${BASE_URL}/quote`, payload, params);

  sleep(1); // pause before the next lap.
}

Three key details of this function:

  • export default. The word default isn't just any name: it's the export k6 looks for to know what to run. When you run k6 run quote_loop.js, k6 takes the default function and turns it into each VU's loop. You can have other functions in the file, but default is the one that repeats. (There are exceptions —setup and teardown— that we'll see at the end of the lesson.)
  • There's no visible loop. Inside the function there's a request and a pause: one lap. The for that repeats it isn't in your code; k6 puts it around the function. You write the body of one iteration, not the loop.
  • The body runs whole, in order, each time. First the request, then the sleep. When the sleep finishes, the iteration ends and the next one starts from the first line. That cycle —top to bottom, and again— is the VU's heartbeat.

If you ran this script with k6 run quote_loop.js without options, k6 would use its default —1 VU doing 1 iteration— and exit. For the VU to repeat the script for a while, you need to tell it how long (with duration) or how many iterations (with iterations); that's lesson 6. Here what matters is the shape: a function that is one lap, and a loop that wraps it.

The same loop, run in Python

Now the face that does run. We model a VU with a Python thread that repeats a default_fn() script in a while loop, against the Reservo API, until a duration is met. It's exactly the VU loop, made explicit:

# A single VU: a thread that repeats the default_fn() script in a loop.
import json, time, urllib.request

BASE_URL = "http://127.0.0.1:PORT"  # the real port is assigned by the OS

def default_fn():
    """The script: one lap of a user's work (one iteration)."""
    payload = json.dumps({"room": "Focus", "tier": "basic", "hours": 3}).encode()
    req = urllib.request.Request(
        f"{BASE_URL}/quote", data=payload,
        headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req) as res:
        res.read()
    time.sleep(1)  # think time, like sleep(1) in k6

def vu_loop(duration_s):
    """The VU loop: repeats the script until the duration is met."""
    iterations = 0
    deadline = time.perf_counter() + duration_s
    while time.perf_counter() < deadline:
        default_fn()     # one lap = one iteration
        iterations += 1
    return iterations

Notice the mapping, line by line: default_fn() is k6's export default function; the while ... default_fn() is the loop k6 puts in on its own; iterations += 1 counts each lap; time.sleep(1) is the sleep(1). The only thing that in k6 is hidden (the loop) is here in plain sight, so you can count it.

In the module's complete generator, this loop runs inside a ThreadPoolExecutor with max_workers = number of VUs; with a single VU, it's literally the vu_loop above running in a thread. Let's run it with 1 VU for 5 seconds, with sleep(1) in between.

What to expect. A VU that does one lap per second (because of the sleep(1)) should complete around 5 iterations in 5 seconds. This is the real output of the generator in this environment (1 VU, 5 s, 1000 ms think time):

----------------------------------------------------------
  Python load generator  ->  1 VUs / 5s / think 1000ms
----------------------------------------------------------
  vus............: 1
  real duration..: 5.05s
  iterations.....: 5   (1.0/s)
  checks.........: 100.00%   (10 of 10)
    status is 200....: 5  ok / 0  fail
    price is correct.: 5  ok / 0  fail
  http_errors....: 0
  req_duration...: avg=2.24ms  min=0.85ms  max=5.64ms
----------------------------------------------------------

Read it slowly, because each figure confirms the model:

  • vus: 1 — a single virtual user (a thread). A single worker at one station.
  • iterations: 5 (1.0/s) — the script was run through completely 5 times in 5 seconds, at a rate of 1 lap per second. That 1/s pace is dictated by the sleep(1): each iteration makes its request (which takes ~2 ms) and then waits a second, so one lap lasts a little over a second. Five seconds, five laps.
  • req_duration avg=2.24ms — the HTTP request itself is very fast; almost the whole second of each iteration is the sleep, not the request. (Notice: the think time does not count as request time. That distinction will matter in module 3.)

There's the VU loop, measured: one thread, five laps, one per second. You didn't write "repeat 5 times"; you wrote one lap and told it "run 5 seconds," and the loop produced the 5 iterations.

What happens if you remove the pause: the loop at full speed

The sleep(1) is what makes the VU go at "human pace." What if you remove it? The loop stays the same —run the script, start again—, but without the pause, the VU hits the API as fast as it can. Let's run the same 1 VU, now for 3 seconds and with no think time:

What to expect. With no pause, the only bound is how long the request takes (~0.2 ms on localhost), so the VU should do thousands of iterations. Real output (1 VU, 3 s, no think time):

----------------------------------------------------------
  Python load generator  ->  1 VUs / 3s / no think time
----------------------------------------------------------
  vus............: 1
  real duration..: 3.00s
  iterations.....: 13598   (4532.5/s)
  checks.........: 100.00%   (27196 of 27196)
    status is 200....: 13598  ok / 0  fail
    price is correct.: 13598  ok / 0  fail
  http_errors....: 0
  req_duration...: avg=0.20ms  min=0.14ms  max=6.27ms
----------------------------------------------------------

The same VU, the same loop, and 13,598 iterations instead of 5. The difference isn't the number of users (still 1) or the duration (3 s instead of 5); it's that without sleep, each lap lasts only as long as the request takes (~0.2 ms), not a second. This teaches two things at once: first, that the VU loop has no pace of its own —it goes as fast as the request and the pauses allow—; and second, why the think time matters so much (lesson 5): a VU with no pause doesn't simulate a human, it simulates a jackhammer. For now, keep the central idea: the loop repeats the script; how many times it repeats depends on how long each lap lasts.

The lifecycle: setup, default, teardown

The default function is the loop, but it's not the only thing a k6 script can have. There are two more special functions, which run only once (not in each iteration) and frame the run:

// The complete lifecycle (shown as content).
export function setup() {
  // Runs ONCE, at the start, before the VUs start.
  // Useful for preparing data (for example, requesting /rooms only once).
  return { rooms: ['Focus', 'Studio', 'Boardroom'] };
}

export default function (data) {
  // Runs IN A LOOP, in each VU, in each iteration.
  // Receives what setup() returned as the `data` argument.
}

export function teardown(data) {
  // Runs ONCE, at the end, after the VUs finish.
  // Useful for cleanup (deleting test bookings, closing resources).
}

The analogy: if default is the card the worker repeats all shift, setup is the factory startup (turning on the machines, loading the raw material) that happens once before the shift, and teardown is the shutdown (turning off, cleaning) that happens once at the end. Only default is in the loop; setup and teardown are the ends.

In this module almost everything lives in default, and we leave it that way: I mention setup/teardown so you recognize the complete lifecycle when you see it, but their in-depth use —preparing shared data, seeding state— shows up when you need it in the realistic scenarios of module 6. What matters here and now: default is the only one of the three that repeats, and that repeating is the VU loop.

Common mistakes

Putting a loop inside default. What happens: someone writes for (let i = 0; i < 100; i++) { http.post(...) } inside default, thinking that "generates load." Why it happens: they haven't internalized that k6 already wraps default in a loop. How to detect it: each iteration makes 100 requests instead of 1, and the summary counts spike nonsensically. How to fix it: put in default a single lap of the script; for more load, raise vus in options, don't add loops. The loop is k6's.

Believing sleep sets the loop's pace from the outside. What happens: someone thinks k6 runs the iterations on a fixed clock (for example, "one per second no matter what"). Why it happens: they confuse the VUs' open loop with a timer. How to detect it: on removing the sleep, the iterations multiply (like the 13,598 above) and it surprises them. How to fix it: understand that the VU loop is open: it goes as fast as each lap lasts. The sleep isn't an external clock; it's time inside the lap that makes it last longer. Without sleep, the lap lasts only the request.

Putting preparation logic inside default. What happens: someone requests GET /rooms in each iteration just to "have the list of rooms," when that list doesn't change. Why it happens: they don't distinguish what goes once (setup) from what goes in each lap (default). How to detect it: you make repeated requests to an endpoint whose result never changes, inflating the load with useless work. How to fix it: what's prepared once —loading fixed data, getting a token— goes in setup and is passed to default as an argument; in default goes only the work a user really repeats.

Exercises

Exercise 1 — How many iterations? A VU runs a script whose body is a ~2 ms request followed by sleep(2) (two seconds of pause). If the run lasts 10 seconds with 1 VU, how many approximate iterations do you expect, and why?

See solution

Around 5 iterations. Each lap lasts a little over 2 seconds (2 ms request + 2 s sleep ≈ 2.002 s). In 10 seconds there's room for about 10 / 2 ≈ 5 laps. The sleep(2) dominates each iteration's duration; the request is negligible next to it. (Compare it with the lesson's real result: sleep(1) gave ~1 iteration per second; sleep(2) gives ~1 every two seconds.)

Exercise 2 — Place each function in the lifecycle. For each task, say whether it goes in setup, in default, or in teardown: (a) quote a room and verify the price; (b) get the list of rooms once to hand it out to the VUs; (c) delete at the end all the test bookings that were created.

See solution
  • (a) In default: it's the work each user repeats in each iteration (quote and verify).
  • (b) In setup: it's done once, before the VUs start, and its result is passed to default as an argument.
  • (c) In teardown: the cleanup that happens once, at the end, after all VUs finish.

Exercise 3 — Read the two runs. Compare the lesson's two real outputs: 1 VU / 5 s / sleep(1) gave 5 iterations; 1 VU / 3 s / no sleep gave 13,598. The number of VUs is the same (1). (a) Why does one give 5 and the other more than 13,000? (b) What does this tell you about what governs the number of iterations?

See solution
  • (a) Because how long each lap of the script lasts changes. With sleep(1), each iteration lasts ~1 second (almost all of it is the pause), so in 5 s there's room for ~5. Without sleep, each iteration lasts only the request (~0.2 ms), so in 3 s there's room for thousands. Same loop, same VU; different duration per lap.
  • (b) That the number of iterations doesn't depend only on the VUs and the duration, but above all on how long each iteration takes (request + think time). The VU loop is open: it produces as many laps as fit in the time, according to how long each one lasts. This is the basis of the VUs-vs-iterations arithmetic of lesson 7.

Summary and next step

In this lesson you opened the heart of the script: the default function is the script a VU runs, and k6 wraps it in a loop that repeats it —each complete lap is an iteration—. The counterintuitive key: you write one lap, not the loop; for more load you put more VUs, not loops inside default. You measured it for real with a Python thread: 1 VU with sleep(1) did 5 iterations in 5 s (one per second), and the same VU with no pause did 13,598 in 3 s —the proof that the loop is open and that each lap's duration governs how many iterations come out—. And you saw the complete lifecycle: setup and teardown run once at the ends; only default is in the loop.

Before moving on you should be able to: write a default function that is one lap of a user's work; explain what an iteration is and why you don't write the loop by hand; and estimate how many iterations a VU will give according to how long each lap lasts.

So far, the lap of the script has been almost empty —one request and one pause—. Lesson 3 fills it with substance: http.get and http.post with JSON body and headers, so the VU really talks to the Reservo API —requesting /rooms, quoting at /quote with its {room, tier, hours} body— and we read what it responds.

Resources