Choosing Between Virtual Users and Arrival Rate in k6 for Throughput Control
Learn when to use Virtual Users versus Arrival Rate executors in k6 to avoid coordinated omission and ensure stable throughput during performance benchmarks.
26 Aug 2025, 01:54 UTC

The Throughput Dilemma: Closed-Loop vs. Open-Loop
When designing a load test in k6, the most critical decision is how to control the volume of traffic. Many engineers default to increasing the number of Virtual Users (VUs), but this creates a closed-loop system. In a closed-loop model, a VU must finish its current iteration (request and response) before starting the next. If the system under test slows down, the VU waits longer, and the total requests per second (RPS) drops automatically.
This behavior masks performance degradation. If your goal is to verify that a system can handle 500 RPS regardless of how slow the responses become, VUs alone will fail you. You need an open-loop system, provided by k6 Arrival Rate executors, which decouple the request rate from the system's response time.
Comparison: VUs vs. Constant Arrival Rate
| Feature | Virtual Users (VUs) | Constant Arrival Rate |
|---|---|---|
| System Model | Closed-loop | Open-loop |
| Primary Control | Concurrency (parallel users) | Throughput (iterations per second) |
| RPS Behavior | Variable; drops as latency increases | Stable; maintained regardless of latency |
| Best Use Case | Stress testing max capacity/concurrency | SLA validation and benchmark targets |
| Risk | Coordinated omission (hidden latency) | Resource exhaustion (VU spikes) |
Trade-offs and Engineering Constraints
The Risk of Coordinated Omission
Using VUs to hit a target RPS often leads to "coordinated omission." When the server stalls, the VUs stall. Because the VUs aren't sending new requests while waiting, the resulting metrics show a lower volume of requests during the slow period, which can artificially smooth out your latency percentiles. Arrival Rate executors avoid this by continuing to trigger new iterations even if previous ones are still pending.
The Cost of VU Allocation
Arrival Rate executors do not magically create requests; they dynamically scale VUs to meet the target rate. If you set a high arrival rate but a low preAllocatedVUs count, k6 will spend CPU cycles allocating new VUs on the fly, which can introduce jitter into your results. Always pre-allocate a baseline of VUs based on expected average response times.
Implementation: Maintaining a Stable RPS
To implement an open-loop test, use the constant-arrival-rate executor. This configuration ensures that k6 attempts to start a specific number of iterations per second, scaling the VU count up to a defined limit if the system slows down.
import http from 'k6/http';
export const options = {
scenarios: {
constant_request_rate: {
executor: 'constant-arrival-rate',
rate: 100, // Target 100 iterations per second
timeUnit: '1s', // Rate is measured per second
duration: '1m', // Total test duration
preAllocatedVUs: 50, // Start with 50 VUs to avoid allocation overhead
maxVUs: 200, // Allow scaling up to 200 VUs to maintain 100 RPS
},
},
};
export default function () {
http.get('https://test-api.example.com/health');
}Execution and Validation
Run this script from your terminal with the following command (requires k6 installed locally; no special permissions needed beyond network access to the target):
k6 run script.jsVerification steps:
- Check
http_reqs: In the final summary, the total requests should closely matchrate * duration(e.g., 100 RPS * 60s = 6,000 requests). - Monitor
vus: Observe the VU count during the test. If the system response time increases, you will see the VU count rise toward themaxVUslimit as k6 attempts to maintain the 100 RPS target. - Identify bottlenecks: If the VU count hits
maxVUsand thehttp_reqsrate begins to drop, you have reached the physical limit of your load generator or the system's ability to handle the concurrency required for that throughput.
Limitations and Practical Checks
Executor behavior described here reflects k6's long-standing design (v0.27+ era through current releases), but verify against the documentation for your installed version with k6 version, since option names and defaults can change. Also note that the load generator machine itself can become the bottleneck: if CPU or memory saturates, k6 may fail to maintain the arrival rate even when the target system is healthy. Watch the dropped_iterations metric to detect this condition.
Since k6 is a stateless load generator, there is no persistent state to roll back on the client side. To stop a test that is consuming too many resources or overwhelming a target server, use Ctrl+C to terminate the process immediately.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.