Virtual Users (VUs) are the core concept in k6 load testing. A VU executes your test script repeatedly, simulating a real user interacting with your system.
What is a Virtual User?
A VU is a virtualized user that runs your test script. Each VU executes independently and concurrently, running the default function in a loop.
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
vus: 10, // 10 virtual users
duration: '30s', // running for 30 seconds
};
export default function() {
// Each VU executes this repeatedly
http.get('https://quickpizza.grafana.com/');
sleep(1);
}
With 10 VUs and a 1-second sleep, this test generates approximately 10 requests per second.
VU Lifecycle
Based on the k6 source code in lib/execution.go, VUs have a sophisticated lifecycle:
Initialization
From lib/execution.go, k6 tracks VU initialization:
initializedVUs - Total number of initialized VUs
activeVUs - VUs currently executing the test script
- VU IDs start from 1 (for backwards compatibility)
- Each VU gets unique local and global identifiers
import exec from 'k6/execution';
export default function() {
console.log(`VU ${exec.vu.idInTest} in iteration ${exec.vu.iterationInScenario}`);
}
VU States
A VU transitions through these states:
Initialized
VU is created and ready to use, stored in the VU buffer.
Active
VU is retrieved from the buffer and executing the default function.
Returned
VU completes an iteration and returns to the buffer for reuse.
Planned vs Unplanned VUs
k6 distinguishes between two types of VUs based on the source code in lib/execution.go:
Planned VUs
Planned VUs are pre-initialized before the test starts. They’re allocated based on your executor configuration.
export const options = {
scenarios: {
contacts: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 50 }, // These 50 VUs are "planned"
{ duration: '1m', target: 50 },
{ duration: '30s', target: 0 },
],
},
},
};
Unplanned VUs
Unplanned VUs are initialized dynamically when arrival-rate executors need more VUs than pre-allocated.
export const options = {
scenarios: {
breakpoint: {
executor: 'ramping-arrival-rate',
preAllocatedVUs: 50, // Planned VUs
maxVUs: 200, // Up to 200 total (150 can be unplanned)
startRate: 50,
timeUnit: '1s',
stages: [
{ duration: '2m', target: 100 },
],
},
},
};
When k6 initializes an unplanned VU, you’ll see: “Initializing an unplanned VU, this may affect test results”
VU Execution Model
From lib/executor/shared_iterations.go, k6 implements a sophisticated VU execution model:
VU Retrieval
The GetPlannedVU() function retrieves VUs from a shared buffer channel:
- VUs wait in a buffer channel when not in use
- Executors borrow VUs when needed
- VUs are returned to the buffer after use
- If retrieval takes longer than 400ms, k6 logs a warning
VU Activation
When a VU starts an iteration:
- It’s retrieved from the buffer
- The
activeVUs counter increments
- It executes the default function
- The
activeVUs counter decrements
- It returns to the buffer
Configuring VUs
Constant VUs
Maintain a fixed number of VUs for the entire test:
export const options = {
vus: 10,
duration: '5m',
};
Ramping VUs
Gradually increase or decrease VUs using stages from examples/stages.js:
export const options = {
stages: [
// Ramp-up from 1 to 5 VUs in 10s
{ duration: '10s', target: 5 },
// Stay at rest on 5 VUs for 5s
{ duration: '5s', target: 5 },
// Ramp-down from 5 to 0 VUs for 5s
{ duration: '5s', target: 0 },
],
};
Per-Scenario VUs
Different scenarios can use different VU configurations:
export const options = {
scenarios: {
light_load: {
executor: 'constant-vus',
vus: 10,
duration: '5m',
},
heavy_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 100 },
],
startTime: '5m', // Starts after light_load
},
},
};
VU Metrics
k6 automatically emits VU-related metrics defined in metrics/builtin.go:
vus
Current number of active VUs (Gauge metric):
export const options = {
thresholds: {
vus: ['value>0'], // Ensure VUs are running
},
};
vus_max
Maximum number of VUs initialized during the test (Gauge metric).
VU Iterations
From lib/execution.go, k6 tracks two types of iterations:
Full Iterations
Iterations that complete normally are counted in fullIterationsCount:
import { Counter } from 'k6/metrics';
const myIterations = new Counter('my_iterations');
export default function() {
// Complete iteration
myIterations.add(1);
}
Interrupted Iterations
Iterations cut short by:
- Test duration ending
- VU ramping down
- Manual test interruption (Ctrl+C)
- Threshold failures with
abortOnFail
These are tracked in interruptedIterationsCount and reflected in the dropped_iterations metric.
VU Resources and Memory
Each VU maintains its own:
- JavaScript runtime (Sobek/goja)
- Execution context
- Variable scope
- Iteration state
VUs are resource-intensive. A single machine typically handles 100-1000 VUs depending on test complexity and hardware.
VU Execution Patterns
Shared Iterations
From lib/executor/shared_iterations.go, VUs share a total iteration count:
export const options = {
scenarios: {
shared: {
executor: 'shared-iterations',
vus: 10,
iterations: 100, // 100 total iterations shared among 10 VUs
},
},
};
Each VU attempts iterations until the total is reached.
Per-VU Iterations
Each VU executes a specific number of iterations:
export const options = {
scenarios: {
per_vu: {
executor: 'per-vu-iterations',
vus: 10,
iterations: 10, // Each VU does 10 iterations = 100 total
},
},
};
Advanced VU Concepts
VU Identifiers
From lib/execution.go, each VU receives unique identifiers:
import exec from 'k6/execution';
export default function() {
// Local VU ID (unique within this k6 instance)
console.log(`Local VU ID: ${exec.vu.idInInstance}`);
// Global VU ID (unique across distributed k6 instances)
console.log(`Global VU ID: ${exec.vu.idInTest}`);
}
All metrics emitted by a VU are automatically tagged:
import http from 'k6/http';
export default function() {
// Automatically tagged with VU ID
http.get('https://quickpizza.grafana.com/');
}
Graceful Stop
When ramping down, VUs complete their current iteration before stopping:
export const options = {
scenarios: {
ramping: {
executor: 'ramping-vus',
gracefulStop: '30s', // Allow up to 30s for iterations to complete
stages: [
{ duration: '1m', target: 100 },
{ duration: '10s', target: 0 }, // Ramp down
],
},
},
};
Set gracefulStop longer than your longest iteration to avoid interruptions.
Best Practices
Start Small
Begin with a small number of VUs and gradually increase to find limits.
Use Stages for Realistic Load
Ramp up VUs gradually to simulate realistic traffic patterns.
Monitor VU Metrics
Watch vus and vus_max to ensure your test runs as expected.
Consider Arrival Rates
For consistent throughput, use arrival-rate executors instead of VU-based executors.
Common Patterns
Load Test Pattern
export const options = {
stages: [
{ duration: '5m', target: 100 }, // Ramp up
{ duration: '10m', target: 100 }, // Stay at peak
{ duration: '5m', target: 0 }, // Ramp down
],
};
Stress Test Pattern
export const options = {
stages: [
{ duration: '2m', target: 100 },
{ duration: '5m', target: 100 },
{ duration: '2m', target: 200 },
{ duration: '5m', target: 200 },
{ duration: '2m', target: 300 },
{ duration: '5m', target: 300 },
{ duration: '10m', target: 0 },
],
};
Virtual Users are the foundation of k6 load testing. Understanding how they work enables you to design effective, realistic load tests.