skip to content

In a k6 script, which code runs in the init context, and how often does it run?

level: juniorimportance: must knowfreq 72%

answer

  1. module scope, not a lifecycle function
  2. once per virtual user
  3. each VU has its own runtime
  4. runs before that VU's first iteration

basics

~20 s

Everything 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 s

In 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 lines
javascript
import 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

for a junior

Recall the boundary: anything outside setup, teardown, handleSummary or the default function is init code, and it runs before the test traffic starts.

for a middle

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.

for a senior

Show that you move per-iteration work into init deliberately, and that you know setup, teardown and handleSummary each pay their own init pass.

for a principal

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