k6 virtual users don’t actually exist as persistent, independent processes; they’re just slices of execution time within a single Go runtime.
Let’s see k6 in action. Imagine we want to simulate 100 users hitting an API endpoint, but we don’t want to blast it all at once. We want to gradually increase the load, then hold it steady.
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up to 50 users over 1 minute
{ duration: '2m', target: 100 }, // Ramp up to 100 users over 2 minutes
{ duration: '3m', target: 100 }, // Stay at 100 users for 3 minutes
{ duration: '1m', target: 0 }, // Ramp down to 0 users over 1 minute
],
};
export default function () {
http.get('https://test.k6.io');
sleep(1); // Simulate user thinking time
}
When you run this script with k6 run your_script.js, k6 starts executing the default function. The options object, specifically the stages array, dictates how the virtual user (VU) count changes over time. Each object in the stages array defines a period: duration is how long that stage lasts, and target is the number of VUs to reach by the end of that duration.
The stages array defines a performance test’s load profile. It’s a sequence of target VU counts and durations that k6 interpolates between. k6 manages the creation and destruction of these VUs dynamically. If a stage targets 50 VUs and the previous stage had 20, k6 will spin up 30 new VUs over the course of that stage’s duration. Conversely, if the target is lower, k6 will gracefully shut down VUs.
The most surprising thing about k6’s virtual users is that they aren’t truly "virtual" in the sense of being separate OS processes or threads. Instead, k6 uses Goroutines, which are lightweight, concurrent functions managed by the Go runtime. A single k6 process can manage thousands of these Goroutines, each representing a virtual user. This allows for extremely high concurrency with minimal resource overhead compared to traditional threading models.
When you configure stages, k6 doesn’t just instantly jump to the target VU count. It performs a linear ramp-up or ramp-down. If you go from 10 VUs to 100 VUs over 2 minutes, k6 will add approximately 90 VUs / 120 seconds = 0.75 VUs per second. This smooth transition is crucial for observing how your system behaves under increasing load without causing an immediate, artificial spike.
The sleep(1) in the default function is important. It simulates the time a real user would spend between actions. Without it, each VU would fire off requests as fast as possible, which isn’t a realistic user behavior and can skew results. This "think time" is a critical part of accurately modeling user interactions.
Many users focus on the target VU count and duration in stages, but the executor option is also powerful. While per-vu-iterations is the default, you can use constant-vus for fixed load or ramping-vus to explicitly define the ramp-up/down behavior with separate startRate and timeUnit. This offers more granular control over how VUs are introduced and removed.
The next concept to explore is how to effectively use thresholds to automatically pass or fail your k6 tests based on performance metrics.