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 finishEach 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:
importstatements- Constant declarations
open()calls to read local files- Creating
SharedArrayinstances 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 intosetup(),default(), orteardown(). - Expecting module-level variables to sync across VUs. If you set
let count = 0at the top level and increment it in the default function, each VU has its owncount. 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.
- Take the script from the previous chapter — the one that makes a GET request with a check.
- Add
export function setup()that makes a POST tohttps://httpbin.org/postwithJSON.stringify({ init: true })as the body. Return{ setupTime: Date.now() }. - Modify
export default function (data)to accept thedataparameter — add a check thatdata.setupTimeis a number. - Add
export function teardown(data)that logs'Test duration: ' + (Date.now() - data.setupTime) + 'ms'. - Run with
vus: 3, duration: '15s'. Confirm the setup and teardown messages appear exactly once each, while the default function runs many times. - Try adding an HTTP call directly in init code (outside any function). Read the error K6 throws, then remove it.