Skip to content
Knowledge Hub

Load testing with k6: measure real RPS instead of the flattering numbers of a for loop Intermediate

Estimated reading time: 7 min read

There is a flash sale next week. The product team expects 150 orders/second on POST /orders and many views of GET /products/:id. The target: p95 under 300 ms, errors under 1%.

Last time the team measured with a for loop making sequential calls and took the average. The numbers looked great, but a for loop has only one request in flight: the server never sees a queue.

This article answers: how do you know how many RPS the API can take with p95 < 300 ms, and why does the way you generate load decide the number you see?

Based on the k6 docs “Load test types”; every type uses a ramp-up, hold, ramp-down shape.

Situation Choose Condition / load shape
You just wrote a script and want to know the script and system work Smoke Very low load, a few seconds to a few minutes
Check normal expected load (a regular day) Average-load Average production load, 5–60 minutes, with ramp-up
Check higher-than-normal load (flash sale) Stress Above-average load, 5–60 minutes
Find the breaking point, maximum capacity Breakpoint Increase gradually until the system breaks
Traffic jumps suddenly (sale opens at midnight) Spike Very high load, a sharp multiple, a few minutes
Memory/connection leaks, disk filling over time Soak Average load, lasting many hours
You need to hold the exact target RPS Executor constant-arrival-rate / ramping-arrival-rate Open model
Simulate N concurrent users with think time Executor constant-vus / ramping-vus Closed model; do not use it to conclude sustainable RPS

A VU (virtual user) is a loop running the script’s default function. An iteration is one run of that function. How k6 decides when to start a new iteration splits into two models:

Closed model Open model
A new iteration starts when That VU’s previous iteration finishes It is scheduled, without waiting for the previous iteration
k6 executors constant-vus, ramping-vus constant-arrival-rate, ramping-arrival-rate
When the server slows down The sent load drops accordingly The load stays the same and the queue grows

A slow server makes closed-model iterations longer, so the arrival rate drops. The k6 docs call this coordinated omission: the load generator skips exactly the requests it should have sent while the server was congested.

Flash-sale users do not wait for the previous person to be served before clicking “Place order”. So the question “can it take 150 RPS” needs an open model.

Three built-in metrics to read:

  • http_req_duration (Trend): sending + waiting + receiving, excluding DNS and connection setup.
  • iteration_duration (Trend): the time of one complete iteration.
  • dropped_iterations (Counter): iterations that could not start because VUs ran out (with arrival-rate executors). Non-zero means k6 did not reach the RPS you set.

The demo uses a simulated server: POST /orders needs one of 4 slots (like a DB connection pool), and each order holds a slot for 40 ms. Theoretical capacity: 4 × 1000 / 40 = 100 orders/second. Without a free slot, the request queues.

Same server, two ways of generating load, two opposite conclusions:

The server slows down: the closed model reduces load itself and passes, the open model holds 150/s and exposes the queue
The server slows down: the closed model reduces load itself and passes, the open model holds 150/s and exposes the queueStep 1/4: The same server has 4 DB slots at 40 ms per order. The closed model keeps 10 requests in flight; the open model fires 150 requests/second.test run time10 requests in flight4 slots busyqueue ≤ 6150 requests/secondClosed modelconstant-vus, 10 VUsServer /orders4 slots × 40 ms ≈ 100/sOpen modelconstant-arrival-rate 150/s
Step 1/4

The same server has 4 DB slots at 40 ms per order. The closed model keeps 10 requests in flight; the open model fires 150 requests/second.

State after each step:

  1. The server saturates at ~100/s: closed VUs wait for responses, so they send later; the open model stays on schedule.
  2. Divergence: the closed model settles at ≈ 98 RPS with a maximum server queue of 5. The open model pushes the queue to 195, exhausts 200 VUs and drops 1372 iterations.
  3. Conclusion: the closed model passes (p95 = 124 ms) despite never sending 150 RPS; the open model fails (p95 = 2.08 s).

Little’s law explains it: RPS = concurrent requests / latency = 10 / 0.102 s ≈ 98. When latency rises, RPS drops by itself.

Thresholds are the pass/fail criteria for the whole run. When a threshold fails, k6 exits with a non-zero code (the demo shows 99), enough for CI to block a release:

thresholds: {
http_req_duration: ['p(95)<300'], // ms, applies to a Trend
http_req_failed: ['rate<0.01'], // under 1% failed requests
},

Checks only record pass/fail per response and do not fail the test. If you want checks to block CI, add a checks: ['rate>0.99'] threshold.

Other points:

  • constant-arrival-rate needs rate, duration, preAllocatedVUs; maxVUs defaults to preAllocatedVUs. When VUs run out, k6 warns Insufficient VUs.
  • Multiple endpoints: one scenario per endpoint, with thresholds by tag ('http_req_duration{scenario:orders}') so a fast GET /products/:id does not pull down the orders p95.
  • Read percentiles, not averages. The open-model run had avg=1.77s but min=40ms: the average mixes early-test requests with requests stuck in the queue and describes no actual user.

Thresholds should come from the SLO: if the SLO is “99% of requests under 300 ms”, p(95) is too loose; use p(99)<300. Load tests check before release; the error budget measures after release (see SLI, SLO).

The demo lives in examples/src/load-testing-k6/: the server.ts server (port 7020) and three k6 scripts.

Terminal window
npx --prefix examples ts-node examples/src/load-testing-k6/server.ts
cd examples/src/load-testing-k6
docker run --rm -i grafana/k6:2.3.0 run - < closed-vus.js
docker run --rm -i grafana/k6:2.3.0 run - < open-arrival.js
docker run --rm -i grafana/k6:2.3.0 run -e RATE=90 - < open-arrival.js
Actual output (trimmed) of the three runs
# closed-vus.js — constant-vus, 10 VU, 30s
✓ 'p(95)<300' p(95)=123.88ms
✓ 'rate<0.01' rate=0.00%
http_req_duration: avg=101.99ms min=43.64ms med=95.22ms max=160.83ms p(90)=123.31ms p(95)=123.88ms
http_reqs........: 2942 97.706456/s
exit=0 server: {"served":2942,"maxQueue":5}
# open-arrival.js — constant-arrival-rate 150/s, preAllocatedVUs 50, maxVUs 200, 30s
level=warning msg="Insufficient VUs, reached 200 active VUs and cannot initialize more"
✗ 'p(95)<300' p(95)=2.08s
http_req_duration: avg=1.77s min=40.02ms med=2.02s max=2.15s p(90)=2.04s p(95)=2.08s
http_reqs........: 3129 97.611322/s
dropped_iterations: 1372 42.80049/s
level=error msg="thresholds on metrics 'http_req_duration' have been crossed"
exit=99 server: {"served":3129,"maxQueue":195}
# open-arrival.js — RATE=90
✓ 'p(95)<300' p(95)=48.36ms
http_reqs........: 2701 89.865909/s
exit=0 server: {"served":2701,"maxQueue":9}

Comparison:

Run Actual RPS p95 dropped_iterations Exit
constant-vus 10 VU 97.7 124 ms — 0 (pass)
constant-arrival-rate 150/s 97.6 2.08 s 1372 99
constant-arrival-rate 90/s 89.9 48 ms 0 0

The first two runs reach the same ~98 RPS because that is all the server can do; the closed model hides how long the rest of the load would have had to wait.

Machine and limits: Apple M5, Docker 29.5.3, Node 24.17, k6 2.3.0; k6 and the server run on the same machine. Absolute numbers change every run; only the closed/open trend is reliable.

The stress-ramp.js script finds the breaking point: ramping-arrival-rate rises from 50 to 150/s over 60 seconds, with a p95 < 300 ms threshold using abortOnFail. Actual output (trimmed):

running (0m38.0s), 056/061 VUs, 3035 complete and 0 interrupted iterations
http_req_duration: med=41.89ms p(95)=436.25ms dropped_iterations: 11
level=error msg="thresholds on metrics 'http_req_duration' were crossed; at least one has abortOnFail enabled, stopping test prematurely"

Completed iterations per second stall at ~100/s while the target load keeps rising; the number of waiting VUs jumps, p95 crosses the threshold and the test stops itself at second 38 (target ~113/s at that point), exit code 99.

The breaking point matches the server’s ceiling of 4 slots × 40 ms.

Open model (arrival rate)

  • Holds the exact target RPS; a slow server does not drag the load down.
  • Exposes real queues and tail latency, matching how independent users arrive.

Things to watch

  • You must size preAllocatedVUs/maxVUs; too low and you miss the RPS, too high and it costs RAM on the k6 machine.
  • When the server collapses, VUs pile up fast and the load generator can choke before the server.
  1. Write down the test’s question (target RPS, endpoint, p95/p99 threshold, error rate) from the SLO before writing the script.
  2. Use constant-arrival-rate or ramping-arrival-rate when drawing conclusions about RPS; check that dropped_iterations is 0.
  3. Set thresholds on http_req_duration and http_req_failed, and on checks if needed; let CI fail on the exit code.
  4. Run k6 on a machine separate from the server, close to the production network; monitor the k6 machine’s CPU/RAM during the run.
  5. Use production-like data (many products, real ID distribution); do not hit the same cached ID forever.
  6. Include a warm-up (ramp-up) so JIT, caches and pools stabilize; do not treat the first few seconds as official numbers.
  7. Turn on server-side monitoring (DB pool, queues, CPU) at the same time to find the bottleneck, rather than only looking at the k6 summary.

The most common mistake:

Asking about RPS but generating load with VUs

Common

scenarios: { orders: { executor: 'constant-vus', vus: 10, duration: '30s' } }
// passes with p95=124ms, but only ~98 RPS

Prefer

scenarios: { orders: { executor: 'constant-arrival-rate', rate: 150,
timeUnit: '1s', duration: '30s', preAllocatedVUs: 50, maxVUs: 200 } }

Why

The closed model reduces load by itself when the server slows; the open model holds 150/s, so the real p95 shows.

The second common mistake happens when reading the output:

Misconception Consequence What to do
A sequential for loop measures capacity Only 1 concurrent request, no queue Use an arrival-rate executor
Reaching http_reqs means reaching the configured RPS Ignores dropped_iterations Compare http_reqs/s with rate, check dropped
Adding retries in the script when errors occur Retries multiply load and hide real errors Measure errors; see retry, backoff, jitter
  1. With constant-vus at 10 VUs, if latency doubles, how does the sent RPS change, and why?
  2. In the 150/s run, http_reqs was only 97.6/s. Which metric tells you where the rest went?
  3. Why do checks show 100% green while k6 still exits with code 99?
  4. The SLO is “99% of requests under 300 ms”. Is a p(95)<300 threshold enough, and why?