Diagnosing k6 Virtual User Saturation: When the Load Generator Becomes the Bottleneck
High response times with a calm target server often mean your k6 load generator is saturated. Learn how to diagnose VU memory pressure and right-size your test.
16 Sept 2025, 02:55 UTC

The symptom: generator-bound load tests
Your k6 summary shows http_req_duration climbing, but the target server's CPU and memory look calm. The usual reaction—blaming the application—misses the real problem: the machine running k6 has exhausted its own resources. This condition is Virtual User (VU) saturation, and it inflates latency metrics before the system under test has felt the intended load.
The useful takeaway is that adding more VUs makes the problem worse. The fix is to right-size the generator so its hardware limits do not masquerade as application latency.
In k6, each virtual user is a separate JavaScript runtime instance. That isolation is useful for test logic, but it means memory and CPU scale directly with concurrency. When the generator runs out of headroom, requests queue inside the tool and response times skew upward.
Cause and diagnostic signals
| Observation | Diagnostic meaning |
|---|---|
High http_req_duration with calm target telemetry | Requests are queuing on the generator, not the server |
| k6 process memory grows linearly with VU count | Expected per-VU runtime overhead; risk when it nears physical RAM |
http_req_connecting stays low and stable | Network handshake is not the cause; look at generator resource use |
Container exits with code 137 or OOMKilled=true | Kernel or cgroup terminated k6 for exceeding its memory limit |
Ordered checks
Check 1: Compare generator and target telemetry during ramp-up
Start the test and watch both machines. On the load generator, identify the k6 process and track its CPU and memory. On the target, watch server-side metrics over the same interval.
# Run on the load generator host; requires permission to view process stats
top -p $(pgrep -f k6)
If k6 is containerized, run docker stats <container_name_or_id> on the Docker host instead.
Expected check: If generator CPU or memory climbs sharply while the target stays flat, the generator is the bottleneck.
Check 2: Project memory from your VU allocation
Each VU consumes memory for its JavaScript runtime. A rough planning heuristic is 1–5 MB per VU depending on script complexity, but this figure varies by k6 version and workload. Validate it against your installed release before sizing a large test.
export const options = {
vus: 200,
iterations: 20000
};
Expected check: Multiply your VU count by the per-VU estimate for your environment. If the projection approaches or exceeds available RAM, saturation is likely.
Check 3: Inspect logs and container state for memory pressure
Review k6 output for out-of-memory errors or long garbage-collection pauses. In containerized environments, confirm whether the runtime was killed.
# Run on the Docker host to check container kill status
docker inspect --format='{{.State.OOMKilled}}' <container_name_or_id>
Expected check: A true result or repeated allocation failures in k6 logs confirms memory exhaustion.
Fixes tied to findings
Fix A: Reduce VUs and increase shared iterations
If Check 2 shows over-allocation, lower the vus value and raise iterations so the total request count stays constant. Fewer concurrent runtimes means less memory pressure while preserving overall workload volume.
export const options = {
vus: 50,
iterations: 20000
};
Risk: Lower concurrency changes the arrival pattern. Verify that the reduced VU count still produces the intended request rate against the target.
Fix B: Lower per-VU runtime overhead
If Check 3 shows pressure even at moderate VU counts, reduce the cost of each runtime. Move static data outside the default function so it is allocated once per VU rather than recreated on every iteration.
import { check } from 'k6';
const staticLookup = new Array(1000).fill(0); // allocated once per VU
export default function () {
check(true, { 'always true': (v) => v === true });
}
You can also run k6 in base compatibility mode to trim non-essential bindings:
# Run in your terminal where k6 is installed
k6 run --compatibility-mode=base script.js
Risk: Base mode may not support every module or API. Test script compatibility before adopting it for a full run.
Escalation criteria
Move to distributed execution or larger hardware when:
- The memory-safe VU count is still below the concurrency your test requires.
- A single-VU baseline already saturates the target server.
- The generator begins swapping to disk under load.
Verification and limitations
Establish a clean baseline with one VU to record a clean response time. Then increase VUs gradually while monitoring generator memory. When response times plateau or memory usage climbs steeply, you have found the practical ceiling for that hardware. Compare a local run against a distributed run to confirm whether the limit is the generator or the test architecture.
Limitations: Per-VU memory figures are approximate and depend on k6 version, script complexity, and imported modules. Container memory limits must account for both the k6 binary and its JavaScript runtimes; a limit set too low triggers OOMKills before k6 can report final metrics. Network latency can mimic saturation, so isolate handshake time using http_req_connecting before concluding the generator is at fault. Because footprints have shifted across k6 releases, verify the estimates in this guide against your installed version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.