skip to content

In k6, a VU mutates the object setup() returned. Which later reads of it see that change?

level: seniorimportance: should knowfreq 42%

answer

  1. copies, not one shared object
  2. decoded lazily, and only once
  3. the boundary is the VU, not the iteration
  4. teardown re-reads the original bytes
  5. no write-back channel exists

basics

~20 s

Only that VU's own later iterations. k6 decodes the setup JSON once per VU and reuses that object for every iteration of that VU, so a mutation persists within the VU but never reaches another VU or teardown().

solid answer

~30 s

k6 keeps `setup()`'s return value as raw JSON bytes on the runner. Each VU decodes those bytes **lazily, on its first iteration**, and caches the resulting object on itself; `teardown()` decodes them again into a third, separate object. So the copies are per-VU, not per-iteration. Writing `data.token = 'refreshed'` inside the default function is still visible on that same VU's next iteration, and is invisible to every other VU and to `teardown()`, which always sees what `setup()` originally produced. Nothing written in VU code travels backwards: k6 has no built-in write-back channel from a VU to the setup data.

code

javascript · 18 lines
javascript
import exec from 'k6/execution';

export const options = { vus: 2, iterations: 4 };

export function setup() {
  return { counter: 0 };
}

export default function (data) {
  data.counter += 1;
  // Each VU counts only its own iterations: 1, 2 for VU 1 and 1, 2 for VU 2.
  console.log(`vu=${exec.vu.idInTest} counter=${data.counter}`);
}

export function teardown(data) {
  // Always 0 - teardown decodes the bytes setup() produced.
  console.log(`teardown counter=${data.counter}`);
}

go deeper

for a junior

Treat the data argument as read-only. Anything you write into it stays with your own virtual user and is not a way to share state with anyone else.

for a middle

Explain the caching: one JSON decode per VU, cached on the VU, reused for every iteration, and a separate decode for teardown from the same original bytes.

for a senior

Recognise the symptom in production. A per-VU refresh storm after a token expires, or a teardown that logs out with a stale credential, is this copy semantics showing up as an incident.

for a principal

Frame the constraint as a design choice: immutability is what makes one script run identically on a laptop and across a fleet, so cross-VU state belongs in metrics or an external store.

## Where the value actually lives After `setup()` returns, k6 holds exactly one artefact: a byte slice containing the JSON encoding of the returned value. It is not a JavaScript object, and no VU has a reference to it. Everything downstream is reconstructed from those bytes. The order of events, precisely: 1. `setup()` returns; k6 encodes the value to JSON and stores the bytes on the runner. 2. VU 1 begins its first iteration. k6 notices it has no decoded value yet, decodes the bytes, and **caches the result on VU 1**. 3. VU 1 runs its second iteration. k6 finds the cached value and passes **the same object** in again. 4. VU 2 does the same thing from the same original bytes, producing a value that has nothing to do with VU 1's. 5. All executors finish. `teardown()` decodes the original bytes one final time into its own object. k6's own source comments explain the choice: decoding once per VU rather than once per iteration keeps VUs isolated without burning CPU re-parsing the same document millions of times mid-test. ## Per-VU, not per-iteration — the part that surprises people The documentation's mental model is "each stage and each VU gets a fresh copy", and that is true at VU granularity. It is **not** true at iteration granularity, and the difference is where real bugs live: | Write made in VU code | Visible to that VU's next iteration | Visible to other VUs | Visible to `teardown()` | |---|---|---|---| | `data.token = 'refreshed'` | **yes** | no | no | | `data.counter++` | **yes** | no | no | | `data = { token: 'x' }` (rebinding the parameter) | no | no | no | Rebinding the parameter is the odd row: assigning to `data` itself only changes that call's local binding, and the next iteration is handed the cached object again. Two engineers can therefore build opposite and equally wrong models from experience. One mutates `data` inside a single VU, sees the change survive, and concludes the setup object is global state. The other watches VU 2 read the original value and concludes the object is rebuilt per iteration. Both are looking at the same one-decode-per-VU cache. ## What `teardown()` sees `teardown()` never sees VU state. It decodes the original bytes, so it receives exactly what `setup()` produced, no matter what any VU did to its own copy. That is what makes the stage reliable for cleanup: the token, the fixture id, or the tenant name it needs to undo is guaranteed to be the one that was created, not something a VU overwrote. The corollary is that you cannot use the setup value to report anything **out** of the run. A counter incremented in VU code and read in `teardown()` will always read its starting value. Aggregation across VUs is what metrics are for; the setup channel is strictly one-way. ## The auth-token trap The concrete version, on the scenario this stage is usually used for. `setup()` mints a bearer token and returns `{ token }`. Half an hour in, the token expires, and a VU that gets a `401` refreshes it and writes the new one into `data.token`: - that VU recovers, because its cached object now carries the new token; - every other VU keeps failing, because each holds its own copy of the stale one; - each of them eventually refreshes independently, so you get one refresh request per VU rather than one per test; - `teardown()` then tries to log out with the **original** token and fails, because it decodes the bytes `setup()` produced. None of that is visible from the script's shape. If a token must outlive the run window, either mint one whose lifetime covers the run, or accept a per-VU refresh and keep `teardown()` from depending on the value staying current. ## Why k6 does not share one object A single shared, mutable object across thousands of VUs would need locking on every access, and it could not exist at all once a test is split across machines — a write on instance A cannot be seen by instance B. Encoding once and decoding per VU is what lets the same script run unchanged on a laptop and across a fleet. The immutability is the price of that portability, not an oversight.

  • Why does k6 decode the setup data once per VU rather than once per iteration?
    For cost. Re-parsing the same JSON document on every iteration of every VU would burn CPU on the load generator throughout the run, which distorts the results you are trying to measure. Decoding once per VU keeps VUs isolated from each other at a fraction of the cost.
  • How would you get a value computed in a k6 VU back into teardown()?
    You would not use the setup channel — it is one-way. Emit a custom metric or a tag from the VU and read the aggregate in the end-of-test output instead, or write the value to an external store that teardown can query.
  • Does reassigning the whole data parameter inside a k6 VU function persist?
    No. Assigning to `data` itself only rebinds that call's parameter; the cached object on the VU is untouched, so the next iteration is handed the original again. Only mutating a property of the object has an effect that survives.

The setup result is a briefing sheet photocopied for each worker. Scribble on your copy and the note is still there on your next shift, but nobody else's copy changes, and the supervisor filing the report at the end prints a fresh one from the original.

saying these in an interview costs you the question

  • Thinks all VUs share one mutable setup object
  • Expects teardown() to observe values VUs wrote
  • Assumes each iteration gets a freshly decoded copy
  • Uses the setup value as a cross-VU counter
  • Believes reassigning the data parameter persists across iterations