Skip to main content
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:
1

Initialized

VU is created and ready to use, stored in the VU buffer.
2

Active

VU is retrieved from the buffer and executing the default function.
3

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:
  1. It’s retrieved from the buffer
  2. The activeVUs counter increments
  3. It executes the default function
  4. The activeVUs counter decrements
  5. 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}`);
}

VU Tags

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

1

Start Small

Begin with a small number of VUs and gradually increase to find limits.
2

Use Stages for Realistic Load

Ramp up VUs gradually to simulate realistic traffic patterns.
3

Monitor VU Metrics

Watch vus and vus_max to ensure your test runs as expected.
4

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.

Build docs developers (and LLMs) love