In a k6 script, which code runs in the init context, and how often does it run?
answer
- module scope, not a lifecycle function
- once per virtual user
- each VU has its own runtime
- runs before that VU's first iteration
basics
~20 sEverything outside a k6 lifecycle function is init code: imports, the exported options object, open() calls, metric constructors. k6 runs it once per virtual user, in that VU's own JavaScript runtime, before that VU's first iteration.
solid answer
~40 sIn k6 the init context is the module scope of the script and its imports — every statement not inside `setup()`, `teardown()`, `handleSummary()`, the `default` function, or a scenario's `exec` function. k6 evaluates it once at load time in a throwaway runtime (`__VU === 0`) to read `options` and discover the exported functions, then once again inside each VU's own fresh runtime before that VU's first iteration. So it runs **once per VU, not once per test**, and module-scope variables are per-VU rather than shared. That is why fixtures are loaded and custom metrics constructed there, and why `http.get()` and `check()` are refused there.
code
javascript · 15 linesimport http from 'k6/http';
import { Trend } from 'k6/metrics';
// init context: runs once per VU, before that VU's first iteration
const users = JSON.parse(open('./users.json')).users;
const loginTime = new Trend('login_time', true);
export const options = { vus: 5, duration: '30s' };
export default function () {
// VU code: runs once per iteration
const user = users[(__VU + __ITER) % users.length];
const res = http.post('https://quickpizza.grafana.com/api/post', JSON.stringify(user));
loginTime.add(res.timings.duration);
}go deeper
Recall the boundary: anything outside setup, teardown, handleSummary or the default function is init code, and it runs before the test traffic starts.
Explain that k6 evaluates init once per VU inside that VU's own runtime, so module-scope variables are per-VU state rather than shared globals.
Show that you move per-iteration work into init deliberately, and that you know setup, teardown and handleSummary each pay their own init pass.
Frame the tradeoff: init is the only reproducible stage, so what a team standardises into it fixes what a test can and cannot vary per VU.
## What counts as init code in a k6 script The **init context** is not a function you write. In k6 it is the *module scope* of your test file and of every module that file imports: every statement that is **not** inside a lifecycle function. Lifecycle functions are `setup()`, `teardown()`, `handleSummary()`, the `default` export, and any other exported function a scenario names through its `exec` key. Everything else — `import` statements, the exported `options` object, `open()` calls, `new Trend('login_time')`, plain helper declarations, and top-level `await` — is init code. k6 draws the line there because init is the only stage in which it can safely do the things that must be identical for every virtual user: read the local filesystem, resolve the module graph, and hand k6 the `options` object before a single request is issued. Once a VU starts iterating, none of that is possible any more, and none of it should need to be. ## How often it runs The answer interviewers are listening for is **once per VU, not once per test**. 1. **At load time**, k6 evaluates the whole script once in a throwaway runtime with `__VU === 0`. This pass reads the exported `options`, discovers which functions are exported and callable, and records every file `open()` touched and every module that was resolved. 2. **Once for every VU k6 initializes.** Each VU gets its own fresh JavaScript runtime and re-runs the entire init context in it, before that VU's first iteration. 3. **Once more for each of `setup()`, `teardown()` and `handleSummary()`**, which k6 runs in a VU of their own (also numbered `__VU === 0`). The `default` function then runs repeatedly on top of that one-time init, once per iteration, for as long as the scenario dictates. Init is never re-entered between iterations, so a fixture parsed at module scope is parsed once per VU no matter how many thousands of iterations that VU performs. ## Every VU gets its own copy Because each VU is a separate JavaScript runtime, module-scope state is **per-VU**, not global. A `let counter = 0` at the top of the file gives you 50 independent counters in a 50-VU run, and each VU's copy survives across that VU's iterations while staying invisible to the other 49. There is no shared heap between VUs to write into and no cross-VU variable to read. That is a deliberate design choice, not a limitation k6 forgot to lift: independent runtimes are what let one script be split across several k6 instances without changing its meaning. Data that must genuinely be shared therefore has to go through a mechanism built for it — `SharedArray` from `k6/data` for read-only fixtures, or the value `setup()` returns, which k6 JSON-encodes and hands to every VU. ## What belongs there | Task in init | Why it must be there | |---|---| | `import http from 'k6/http'` | modules resolve only during init | | `open('./users.json')` | the filesystem is readable only during init | | `export const options = {...}` | k6 reads it from the load-time pass | | `new Trend('login_time')` | metric constructors require the init environment | | declaring helper functions | cheap, and paid once per VU rather than per iteration | The practical payoff is that per-iteration work stays small. Parsing a JSON fixture at module scope costs each VU one parse for the whole run; parsing it inside `default` would repeat that same parse on every single iteration that VU performs. ## What is refused there Init runs before the VU has any execution state, so k6 rejects everything that would need one: - `http.get()`, `http.batch()` and `new http.CookieJar()` — *"Making http requests in the init context is not supported"*; - `check()` and `group()` from the `k6` module; - `.add()` on a custom metric — the constructor is init-only, the `.add()` call is init-*forbidden*; - `exec.vu`, `exec.instance` and `exec.test.options` from `k6/execution`. Internally every one of those guards is the same test — the VU state being absent means "we are still in init" — and the two init-only globals, `open()` and `require()`, test exactly the opposite condition. A violation throws a script exception, which fails the run rather than skipping the statement. Read against this rule, "what is the init context" is really a question about k6's execution model: one script, N independent runtimes, each prepared once and then driven through iterations. Get that model right and the placement rules stop feeling arbitrary — every one of them follows from it.
- If a k6 script declares `let counter = 0` at module scope and increments it in the default function, what does each VU see?Each VU sees its own counter. Module scope is re-evaluated in every VU's separate runtime, so a 50-VU run creates 50 independent variables. Each one persists across that VU's iterations but is invisible to the other 49. Sharing across VUs needs `SharedArray` from `k6/data` or the value returned by `setup()`.
- Does k6 re-run the init context between iterations of the default function?No. Init runs once for the VU and the `default` function is then called repeatedly on top of it. Only per-iteration VU state is reset between iterations — cookies, for example. Module-scope constants such as a parsed fixture are computed once and reused for every iteration that VU performs.
- Which k6 lifecycle stages besides the VUs also execute the init context?`setup()`, `teardown()` and `handleSummary()` each run inside a VU of their own, and k6 builds that VU the same way it builds any other — by evaluating the whole init context first. Those VUs are numbered `__VU === 0`, the same number used by the load-time pass that reads `options`.
Each k6 VU is like a separate cashier who sets up their own till before the shop opens: the same setup instructions, run once each, producing independent tills rather than one shared drawer.
saying these in an interview costs you the question
- Thinks init code runs once for the whole test run
- Believes all VUs share one copy of a module-scope variable
- Puts http.get() at module scope to warm up connections
- Expects init to re-run between iterations of the default function
- Thinks options is read from inside the default function