Virtual users and iterations are the two fundamental units of K6 load. Understanding exactly what they mean — and what they do not mean — prevents the most common configuration mistakes in load testing.
What a virtual user actually is
A virtual user (VU) is a simulated user running the default function in a continuous loop for the duration of the test. Under the hood, each VU is a Go goroutine running its own JavaScript context. This is why K6 can sustain thousands of VUs on a single machine — goroutines are far lighter than OS threads, and K6 shares JavaScript engine resources efficiently across VUs.
Each VU is completely independent:
- Its own JavaScript execution context — no shared variables with other VUs
- Its own cookie jar — tracks cookies per session, just like a real browser
- Its own TCP connection pool
- Its own built-in counters:
__VU(which VU is this, 1-based) and__ITER(how many iterations this VU has run, 0-based)
export default function () {
console.log(`VU ${__VU}, iteration ${__ITER}`);
}With 3 VUs running 4 iterations each, you will see all combinations — VU 1 iterations 0–3, VU 2 iterations 0–3, VU 3 iterations 0–3 — running concurrently and interleaved in the output.
Two ways to configure load
Option 1: VUs + duration (the standard approach)
export const options = {
vus: 10,
duration: '30s',
};10 VUs each loop the default function continuously for 30 seconds. The total number of iterations depends on how long each iteration takes: with a 1-second sleep and 100ms requests, each VU does about 27 iterations in 30 seconds, giving roughly 270 total.
Option 2: VUs + total iterations
export const options = {
vus: 10,
iterations: 100,
};100 iterations total, shared across 10 VUs. K6 assigns iterations to whichever VU is free, like a work queue. The test ends when all 100 are complete, however long that takes. Use this when you need to drive exactly N requests — generating test records, seeding a database, or running a fixed benchmark.
A critical distinction: iterations is the global total, not per-VU. vus: 10, iterations: 100 gives each VU roughly 10 iterations. To run 100 iterations per VU, use the per-vu-iterations executor in the scenarios API (Chapter 5).
The VU model
- – Own JS context
- – Own cookie jar
- – Own TCP connections
- – __VU and __ITER built-ins
- – Start → run setup data
- – Loop: call default() → sleep → repeat
- – Stop when duration ends
- – Not OS threads
- – Not concurrent requests
- – Not independent processes
- vus + duration (time-based) –
- vus + iterations (count-based) –
- stages (ramping — next lesson) –
- scenarios (advanced — Chapter 5) –
VUs are not concurrent requests
This distinction trips up almost everyone coming from JMeter. In JMeter, a thread maps roughly to a concurrent request. In K6, a VU maps to a concurrent user — and a user pauses between actions.
Consider this script:
import http from 'k6/http';
import { sleep } from 'k6';
export const options = { vus: 10, duration: '30s' };
export default function () {
http.get('https://test.k6.io'); // ~120ms
sleep(1); // 1000ms
}Each iteration takes roughly 1.12 seconds. Each VU completes about 26 iterations in 30 seconds. 10 VUs × 26 iterations ≈ 260 requests total, or roughly 8–9 requests per second.
At any given moment, some VUs are waiting for the server, some are sleeping. Only a fraction are actively transmitting a request. The maximum concurrent requests equals the VU count, but the actual concurrent requests at any instant is always much lower.
If you want to drive a precise number of requests per second (regardless of VU count and response time), use the constant-arrival-rate executor in Chapter 5.
The role of sleep()
sleep() is not a courtesy to the server. It is what separates a realistic load test from a synthetic hammering test. Real users:
- Request a resource — wait for it to load
- Read or interact with the content (think time)
- Trigger the next request
Without sleep(), each VU sends requests as fast as the server responds. A fast server produces implausibly high throughput numbers. More importantly, without think time your concurrency model is wrong — you are simulating automated bots, not humans.
import { sleep } from 'k6';
export default function () {
// ... HTTP requests ...
sleep(Math.random() * 2 + 1); // random pause: 1s to 3s
}Randomising the sleep time prevents all VUs from synchronising and sending requests at the same millisecond — a pattern known as a "thundering herd" that does not reflect natural traffic distribution.
Common think time guidelines:
- Web applications: 2–5 seconds between page loads
- Mobile API clients (background sync): 1–3 seconds
- Interactive forms (user typing): 5–15 seconds
- Batch API integrations: 0 (but use arrival-rate executors)
Using __VU and __ITER for data distribution
The built-in __VU and __ITER values are useful for distributing test data without a shared data structure:
const users = [
{ email: 'user1@test.com', password: 'Pass1' },
{ email: 'user2@test.com', password: 'Pass2' },
{ email: 'user3@test.com', password: 'Pass3' },
];
export const options = { vus: 3, duration: '30s' };
export default function () {
// Each VU always uses the same user — deterministic distribution
const user = users[(__VU - 1) % users.length];
// ... use user.email and user.password ...
}(__VU - 1) converts the 1-based VU ID to a 0-based array index. The modulo ensures the index wraps if VU count exceeds the array length. For large datasets, use SharedArray (Chapter 5) instead of inline arrays to avoid memory duplication across VUs.
⚠️ Common mistakes
- Setting
vus: 10, iterations: 100and expecting 1,000 total iterations.iterationsis a global total shared across all VUs. To run 100 iterations per VU, you need theper-vu-iterationsexecutor in the scenarios API. - Removing
sleep()to maximise throughput numbers. This produces results that look impressive in a report but bear no resemblance to real user behaviour, making the test useless for capacity planning. - Assuming adding VUs always increases requests per second. VUs limited by a long
sleep()are mostly idle. Adding more idle VUs does not increase throughput. To increase RPS, reduce think time — or switch to an arrival-rate executor.
🎯 Practice task
Explore the VU–sleep–RPS relationship through measurement. 25 minutes.
- Write a script targeting
https://httpbin.org/get. Addsleep(1). Setvus: 5, duration: '30s'. Run and note thehttp_reqsrate. - Change to
vus: 10. Run again — the rate should approximately double. - Change back to
vus: 5but setsleep(0.5). Run — the rate should be similar to 10 VUs with 1s sleep. - Remove
sleep()entirely withvus: 5. Note how the rate spikes and howhttp_req_durationmetrics change. - Log
VU ${__VU}, iteration ${__ITER}inside the default function. Run withvus: 3, iterations: 9. Confirm you see exactly 9 log lines, distributed across the 3 VUs.