The default Function and Test Lifecycle

8 min read

Every K6 script follows the same execution lifecycle, whether it has 1 virtual user or 10,000. Understanding which code runs when, how many times, and in what context is the foundation for writing tests that produce accurate results.

The four phases of a K6 test

1. Init code      — runs once per VU, before the test starts
2. setup()        — runs once, before all VUs begin
3. default()      — runs many times per VU (the actual test)
4. teardown()     — runs once, after all VUs finish

Each phase has a distinct role. Putting the wrong code in the wrong phase is one of the most common sources of K6 bugs.

Step 1 of 4

Init code

Top-level code outside any function. Runs once for each VU before the test begins. Use it for imports, constants, and opening data files. HTTP requests are NOT allowed here — K6 throws an error if you try.

All four phases in one script

import http from 'k6/http';
import { check } from 'k6';
 
// Init code — runs once per VU, before the test
const BASE_URL = 'https://api.example.com';
 
export const options = {
  vus: 5,
  duration: '30s',
};
 
// Runs once before all VUs start
export function setup() {
  const res = http.post(`${BASE_URL}/auth/login`, {
    email: 'loadtest@example.com',
    password: 'Test@1234',
  });
  const token = res.json('token');
  return { token };
}
 
// Runs many times — once per iteration per VU
export default function (data) {
  const headers = { Authorization: `Bearer ${data.token}` };
  const res = http.get(`${BASE_URL}/users/me`, { headers });
  check(res, { 'status is 200': (r) => r.status === 200 });
}
 
// Runs once after all VUs finish
export function teardown(data) {
  http.post(`${BASE_URL}/auth/logout`, null, {
    headers: { Authorization: `Bearer ${data.token}` },
  });
}

setup() returns { token }. K6 serialises that object and passes it to every call of export default function (data) and to teardown(data) as the data parameter. This is the only built-in mechanism for sharing data from setup to all VUs.

Init code: what it is and what it cannot do

Init code is everything at the top level of your script — imports, constant definitions, export const options. It runs once for each VU before the test begins, in a special "init context."

The critical constraint: HTTP requests are not allowed in init code. K6 enforces this strictly and throws an error at startup. The reason is that init runs during a preparation phase before the K6 scheduler is active.

// ✅ Allowed in init
import http from 'k6/http';
const BASE_URL = 'https://api.example.com';
const testData = open('./users.csv');  // read local file
 
// ❌ Not allowed in init — throws "http requests are not allowed in the init context"
const res = http.get('https://api.example.com/health');

Valid init operations:

  • import statements
  • Constant declarations
  • open() calls to read local files
  • Creating SharedArray instances for test data (Chapter 5)

Why VUs share nothing by default

Each virtual user runs in its own isolated JavaScript context. A variable modified in one VU's iteration is completely invisible to other VUs — just as two browser sessions do not share state.

let counter = 0;  // Each VU has its own copy of this variable
 
export default function () {
  counter++;  // This increments the local VU's counter, not a shared one
  console.log(`VU ${__VU} has run ${counter} iterations`);
}

If you need read-only data shared across VUs, return it from setup(). For large read-only datasets, use SharedArray (Chapter 5). There is no shared mutable state between VUs.

A common trap: authentication in the wrong phase

In JMeter, you might add a login sampler at the start of the thread group and reuse the token on subsequent requests. In K6, that pattern belongs in setup() — not in the default function.

// ❌ Wrong — every VU logs in on every iteration
export default function () {
  const loginRes = http.post('/auth/login', { username: 'test', password: 'pass' });
  const token = loginRes.json('token');
  http.get('/profile', { headers: { Authorization: `Bearer ${token}` } });
}

With 100 VUs and 5-second iterations, this hammers the auth endpoint 20 times per second. It also inflates your latency results because every iteration measures login time along with the actual request.

// ✅ Correct — login once, share token
export function setup() {
  const res = http.post('/auth/login', { username: 'test', password: 'pass' });
  return { token: res.json('token') };
}
 
export default function (data) {
  http.get('/profile', { headers: { Authorization: `Bearer ${data.token}` } });
}

⚠️ Common mistakes

  • Making HTTP calls in init code. K6 throws GoError: http requests are not allowed in the init context. Move all HTTP calls into setup(), default(), or teardown().
  • Expecting module-level variables to sync across VUs. If you set let count = 0 at the top level and increment it in the default function, each VU has its own count. They do not see each other's values.
  • Doing cleanup inside the default function. If the test is interrupted mid-run, the default function stops immediately. teardown() is called even on interruption, making it the only reliable place for cleanup logic.

🎯 Practice task

Rewrite an existing script to properly use the lifecycle. 25 minutes.

  1. Take the script from the previous chapter — the one that makes a GET request with a check.
  2. Add export function setup() that makes a POST to https://httpbin.org/post with JSON.stringify({ init: true }) as the body. Return { setupTime: Date.now() }.
  3. Modify export default function (data) to accept the data parameter — add a check that data.setupTime is a number.
  4. Add export function teardown(data) that logs 'Test duration: ' + (Date.now() - data.setupTime) + 'ms'.
  5. Run with vus: 3, duration: '15s'. Confirm the setup and teardown messages appear exactly once each, while the default function runs many times.
  6. Try adding an HTTP call directly in init code (outside any function). Read the error K6 throws, then remove it.

// tip to track lessons you complete and pick up where you left off across devices.