Load testing with k6: measure real RPS instead of the flattering numbers of a for loop Intermediate
Estimated reading time: 7 min read
The problem
Section titled “The problem”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?
What to choose (summary first)
Section titled “What to choose (summary first)”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 |
Core concepts
Section titled “Core concepts”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.
How it works
Section titled “How it works”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 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:
- The server saturates at ~100/s: closed VUs wait for responses, so they send later; the open model stays on schedule.
- 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.
- 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.
Operational details
Section titled “Operational details”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-rateneedsrate,duration,preAllocatedVUs;maxVUsdefaults topreAllocatedVUs. When VUs run out, k6 warnsInsufficient VUs.- Multiple endpoints: one scenario per endpoint, with thresholds by tag (
'http_req_duration{scenario:orders}') so a fastGET /products/:iddoes not pull down the orders p95. - Read percentiles, not averages. The open-model run had
avg=1.77sbutmin=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).
Real-world example
Section titled “Real-world example”The demo lives in examples/src/load-testing-k6/: the server.ts server (port 7020) and three k6 scripts.
npx --prefix examples ts-node examples/src/load-testing-k6/server.tscd examples/src/load-testing-k6docker run --rm -i grafana/k6:2.3.0 run - < closed-vus.jsdocker run --rm -i grafana/k6:2.3.0 run - < open-arrival.jsdocker run --rm -i grafana/k6:2.3.0 run -e RATE=90 - < open-arrival.jsActual 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/sexit=0 server: {"served":2942,"maxQueue":5}
# open-arrival.js — constant-arrival-rate 150/s, preAllocatedVUs 50, maxVUs 200, 30slevel=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/slevel=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/sexit=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 iterationshttp_req_duration: med=41.89ms p(95)=436.25ms dropped_iterations: 11level=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.
Trade-offs
Section titled “Trade-offs”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.
Production checklist
Section titled “Production checklist”- Write down the test’s question (target RPS, endpoint, p95/p99 threshold, error rate) from the SLO before writing the script.
- Use
constant-arrival-rateorramping-arrival-ratewhen drawing conclusions about RPS; check thatdropped_iterationsis 0. - Set thresholds on
http_req_durationandhttp_req_failed, and onchecksif needed; let CI fail on the exit code. - Run k6 on a machine separate from the server, close to the production network; monitor the k6 machine’s CPU/RAM during the run.
- Use production-like data (many products, real ID distribution); do not hit the same cached ID forever.
- Include a warm-up (ramp-up) so JIT, caches and pools stabilize; do not treat the first few seconds as official numbers.
- 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.
Common mistakes
Section titled “Common mistakes”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 RPSPrefer
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 |
Self-check
Section titled “Self-check”- With
constant-vusat 10 VUs, if latency doubles, how does the sent RPS change, and why? - In the 150/s run,
http_reqswas only 97.6/s. Which metric tells you where the rest went? - Why do checks show 100% green while k6 still exits with code 99?
- The SLO is “99% of requests under 300 ms”. Is a
p(95)<300threshold enough, and why?
Summary
Section titled “Summary”References
Section titled “References”- k6 — Load test types: the goal and load shape of each test type.
- k6 — Open and closed models: how the two models work and coordinated omission.
- k6 — Constant arrival rate: options and how VUs are allocated.
- k6 — Thresholds: syntax,
abortOnFail, and the relationship to checks and exit codes. - k6 — Built-in metrics: definitions of
http_req_duration,dropped_iterations. - Gil Tene — How NOT to Measure Latency: coordinated omission and why to read percentiles.