Load Testing
How much traffic can your service take before it gets slow — and how do you know before your users find out? Throughput, latency percentiles and saturation; smoke, stress, spike and soak tests; k6 scripts with thresholds against a real Go server; open vs closed traffic and the coordinated-omission trap; finding the knee and turning it into a server count.
The 30-second version
Your other tests ask "does it give the right answer?" A load test asks a different question: "does it still give the right answer, quickly, when 500 people ask at once?" A function that takes 20 ms for one user can take 2 seconds when hundreds of users are waiting in line for the same database.
Try it
Here's a tiny simulated server. It has 8 "slots" (think: 8 database connections), and every request holds one slot for about 20 ms. Drag the slider to send more requests per second, and watch 20 seconds play out.
Try 200/s, then 380/s, then 450/s. Up to about 380 per second nothing changes — every request takes ~20 ms. Past ~400/s, the server can't finish requests as fast as they arrive, a line forms, and the wait grows every second the traffic continues. That sudden bend is called the knee, and finding it is the main job of a load test (§09).
What this topic covers
- What to measure — four numbers, and why averages lie (§02).
- Which test to run — smoke, load, stress, spike, soak (§03).
- Hands-on with k6 — a real Go server, a real k6 script, real output, and pass/fail rules for CI (§04–§06).
- Generating traffic correctly — the difference between "50 users clicking" and "300 requests per second", and a measurement trap that makes slow systems look fast (§07–§08).
- Using the results — find the knee, then turn it into "how many servers do we need?" (§09–§10), and how to test somewhere that resembles production (§11).
What to measure: four numbers
Think of a coffee shop at the morning rush. You'd want to know: how many coffees per minute come out (throughput), how long people wait (latency), how many orders come out wrong (errors), and whether the baristas are at 100% with a line out the door (saturation). One number alone never tells the whole story.
1. Throughput
Throughput is how many requests the service finishes per second, written as requests per second (RPS). The key habit: compare it with what you asked for. If the test sends 500/s and only 388/s complete, the other 112 per second are piling up somewhere — a line is forming.
2. Latency — as percentiles, never an average
Latency is how long one request takes, from sending it until the full reply arrives. A load test produces thousands of latencies, and you need to summarize them. Don't use the average. A worked example with 100 requests:
- 98 take 20 ms, 2 get stuck and take 2,000 ms.
- Average = (98 × 20 + 2 × 2,000) ÷ 100 = 59.6 ms. Nobody actually waited 59.6 ms.
- p50 (the median — sort all 100, take the middle) = 20 ms: what a typical request feels.
- p99 (the time 99% of requests stay under) = 2,000 ms: what the unlucky ones feel.
A Percentile says "X% of requests were this fast or faster". Load-test reports usually show p50, p95 and p99. The slow end is called the tail, and it grows first when a system gets busy. For the full story — histograms, why you can never average two p99s — see Observability: percentiles.
3. Error rate
The Error rate is the share of requests that failed: timeouts, refused connections, 500 responses. Read it together with latency, because errors are often fast. Returning "500 Internal Server Error" takes almost no work, so a service that fails half its requests can show lovely latency numbers.
4. Saturation
Saturation is how full your busiest resource is — CPU, memory, database connections — and whether work is waiting for it. It's the early warning: users feel nothing yet, but when the connection pool is busy 97% of the time, a little more traffic will tip it over. Your load-testing tool can't see this; you read it from the server's own metrics during the test (see Observability: metrics).
Practice
Smoke, load, stress, spike, soak
Back to the bridge. Engineers run several different checks: one truck to see the sensors work, a normal rush hour, an overloaded convoy, a sudden stampede, and a month of ordinary traffic to look for slow fatigue cracks. Each answers a different question, and you wouldn't run the last one first.
export const options = {
stages: [
{ duration: "5m", target: 100 }, // ramp up to a normal day
{ duration: "30m", target: 100 }, // stay there
{ duration: "5m", target: 0 }, // ramp down
],
};What each one is for
- A Smoke test is the "does it even work?" check: one or two virtual users for a minute. It catches a broken script or a dead endpoint in seconds, so it runs first — and it's cheap enough to run on every pull request.
- A load test (sometimes "average-load test") replays a normal busy day. It answers "do we meet our targets today?"
- A Stress test goes above normal. It answers "what breaks first, and how?" A good system slows down or rejects extra work politely (see load shedding); a bad one crashes and takes everything with it.
- A Spike test jumps suddenly. It checks the system survives the burst and — just as important — recovers when traffic drops back. Auto-scaling that needs 3 minutes to add servers won't help with a 30-second spike.
- A Soak test runs normal traffic for hours. Some bugs only appear slowly: a Memory leak that grows 10 MB per hour, database connections that are never returned, a log file filling the disk.
- A Breakpoint test keeps raising traffic until a target is broken, to find the maximum. That's how you find the knee in §09.
Ramp up, don't jump
Notice that most shapes ramp up gradually. Starting at full traffic in the first second gives misleading results: caches are empty, connections aren't open yet, and auto-scaling hasn't reacted. The first minutes of any test are a Warm-up; judge the system on the steady part after it.
Your first k6 test against a Go server
options object (how much traffic) and a default function (what one user does). k6 runs that function in a loop for every virtual user, and times every request for you.The target: a server with a bottleneck
To learn anything from a load test, the server needs a realistic limit. This one pretends every request needs one of 8 database connections (a Connection pool) for 15–25 ms. That pool is its Bottleneck: the thing that runs out first.
package main
import (
"fmt"
"log"
"math/rand/v2"
"net/http"
"time"
)
// Only 8 requests can be "at the database" at once — like a pool of 8 connections.
var pool = make(chan struct{}, 8)
func products(w http.ResponseWriter, r *http.Request) {
pool <- struct{}{} // take a slot (waits while all 8 are busy)
defer func() { <-pool }() // give the slot back when the handler returns
work := 15*time.Millisecond + rand.N(10*time.Millisecond) // 15–25 ms of "work"
time.Sleep(work)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"products":[]}`)
}
func main() {
http.HandleFunc("GET /api/products", products)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}In the Node version, acquire and release do the same job by hand: a counter of busy slots plus a list of requests waiting in line. Node handles each request on its single thread, but await sleep(…) lets it serve others while one waits — see Async / Await.
With 8 slots and ~20 ms per request, the server's ceiling should be about 8 ÷ 0.020 s = 400 requests per second. Let's see whether a load test agrees.
Install k6 and write the test
k6 is a single program (written in Go) that runs test scripts written in JavaScript. Install it from your package manager (brew install k6, or the packages listed on the k6 website), or run it with Docker without installing anything:
go run . # start the server (or: node server.mjs)
# in another terminal:
k6 run load.js
# …or with Docker (--network host lets the container reach localhost:8080 on Linux):
docker run --rm -i --network host grafana/k6 run - < load.js
# On Mac/Windows Docker Desktop: drop --network host and use
# http://host.docker.internal:8080 instead of http://localhost:8080 in the scriptimport http from "k6/http";
import { check, sleep } from "k6";
export const options = {
vus: 10, // 10 virtual users…
duration: "30s", // …each looping for 30 seconds
};
export default function () {
const res = http.get("http://localhost:8080/api/products");
check(res, { "status is 200": (r) => r.status === 200 });
sleep(1); // think time: a real user pauses between clicks
}What one virtual user actually does
1import http from "k6/http";2import { check, sleep } from "k6";34export const options = {5 vus: 10, // 10 virtual users…6 duration: "30s", // …each looping for 30 seconds7};89export default function () {10 const res = http.get("http://localhost:8080/api/products");11 check(res, { "status is 200": (r) => r.status === 200 });12 sleep(1); // think time: a real user pauses between clicks13}
k6/http, k6) — this is not Node.js, so npm packages and fetch from Node aren't here. k6 runs the JavaScript inside its own engine, written in Go.The important arithmetic from the last steps: each user does one request every ~1.02 s, so 10 users produce only about 10 requests per second — far below the server's ~400/s. The number of virtual users is not the number of requests per second. §07 shows how to ask for an exact request rate instead.
Reading the results
http_req_duration (latency), http_reqs (throughput), http_req_failed (error rate) and checks. Everything else is supporting detail.Here's what the §04 run printed at the end, trimmed (… marks cut lines) (measured on this machine; your numbers will differ a little):
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
* default: 10 looping VUs for 30s (gracefulStop: 30s)
█ TOTAL RESULTS
checks_total.......: 300 9.767492/s
checks_succeeded...: 100.00% 300 out of 300
checks_failed......: 0.00% 0 out of 300
✓ status is 200
HTTP
http_req_duration..............: avg=21.9ms min=15.74ms med=21.14ms max=66.91ms p(90)=25.31ms p(95)=26.2ms
{ expected_response:true }...: avg=21.9ms min=15.74ms med=21.14ms max=66.91ms p(90)=25.31ms p(95)=26.2ms
http_req_failed................: 0.00% 0 out of 300
http_reqs......................: 300 9.767492/s
…The header line gracefulStop: 30s is the graceful stop: after 30 s of testing, k6 gives iterations still in flight up to 30 more seconds to finish. Click through the lines below to see what each one means:
med is p50 (the typical request), p(95) means 95% of requests were faster than 26.2 ms. Skip avg; use med and p(95). max is one request — useful for debugging, too noisy to set targets on.The verdict for this run
Latency is excellent — p95 of 26 ms is basically the server's 15–25 ms of work plus a little overhead. No errors, all checks passed. But the test proves almost nothing, because the server only saw ~10 requests per second, about 2.5% of what it can handle. The bridge held one car.
Why 10 users made only 10 requests per second
Each virtual user waits for its reply, then sleeps 1 s, then sends again. So one user's loop takes about 21 ms + 1,000 ms ≈ 1.02 s, and:
requests per second ≈ virtual users ÷ (latency + think time) = 10 ÷ 1.02 s ≈ 9.8/s
This is Little's Law in disguise (explained in Worker pools: sizing). Little's Law says L = λ × W: things in the system (L) = arrival rate (λ) × time each one stays (W). Here L = 10 virtual users, W = 1.02 s per loop, so λ = L ÷ W = 10 ÷ 1.02 ≈ 9.8 requests per second. It has an important consequence: with a fixed number of users, if the server gets slower, the users automatically send less. Each user is stuck waiting longer, so fewer requests go out. Keep that in mind — §07 and §08 are about why it's a problem.
To push harder you could remove the sleep or add users. Here's what happened with no sleep and more users (15 s each, measured on this machine):
| Virtual users | Requests/s | p50 | p95 |
|---|---|---|---|
| 4 | 191 | 20.7 ms | 25.2 ms |
| 8 | 384 | 20.8 ms | 25.3 ms |
| 16 | 384 | 41.3 ms | 48.0 ms |
| 64 | 388 | 164.6 ms | 172.6 ms |
Up to 8 users, every user gets its own slot: throughput doubles, latency stays flat. Beyond 8, throughput stops growing at the ~388/s ceiling (a bit under the theoretical 400 because of HTTP overhead), and latency grows instead: 64 users ÷ 388 per second ≈ 165 ms, exactly what p50 shows. Extra users just stand in line.
Useful flags
# show p99 too (k6 shows avg, min, med, max, p(90), p(95) by default)
k6 run --summary-trend-stats "med,p(95),p(99),max" load.js
# also save every single measurement, one JSON object per line, for your own analysis
k6 run --out json=results.json load.jsChecks and thresholds: pass or fail
Checks don't fail anything
It's easy to assume a failed check fails the test. It doesn't. Here's a script whose every single request hits a URL that doesn't exist (measured on this machine):
$ k6 run bad.js # requests /api/nope, checks for status 200
✗ status is 200
↳ 0% — ✓ 0 / ✗ 9417
http_req_duration....: avg=150.69µs min=23.89µs med=122.99µs max=8.16ms p(90)=205.95µs p(95)=252.12µs
http_req_failed......: 100.00% 9417 out of 9417
$ echo $?
0100% of checks failed, 100% of requests failed — and k6 exited with 0, "success". Notice the latency too: a median of 0.12 ms, because a 404 Not Found takes no work. This is the "errors are fast" trap from §02 in real life. A test without thresholds can't fail.
Thresholds: your targets as rules
A Threshold is written in options.thresholds: a metric name, then a list of conditions that must hold over the whole test. Here are the targets from §02, written down:
import http from "k6/http";
import { check } from "k6";
export const options = {
vus: Number(__ENV.VUS || 8), // pass a number in (k6 run -e VUS=64 load.js), or get 8
duration: "30s",
thresholds: {
http_req_failed: ["rate<0.01"], // fewer than 1% of requests fail
http_req_duration: ["p(95)<100", "p(99)<250"], // the latency budget
checks: ["rate>0.99"], // over 99% of checks pass
},
};
export default function () {
const res = http.get("http://localhost:8080/api/products");
check(res, { "status is 200": (r) => r.status === 200 });
}Two real runs against the §04 server, no sleep (measured on this machine). With 8 virtual users:
$ k6 run -e VUS=8 load.js
█ THRESHOLDS
checks
✓ 'rate>0.99' rate=100.00%
http_req_duration
✓ 'p(95)<100' p(95)=25.34ms
✓ 'p(99)<250' p(99)=26.05ms
http_req_failed
✓ 'rate<0.01' rate=0.00%
$ echo $?
0With 64 virtual users, the same script — remember from §05 that 56 of them are standing in line at any moment:
$ k6 run -e VUS=64 load.js
level=error msg="thresholds on metrics 'http_req_duration' have been crossed"
█ THRESHOLDS
http_req_duration
✗ 'p(95)<100' p(95)=180.9ms
✓ 'p(99)<250' p(99)=193.47ms
…
$ echo $?
99One rule broken, so the whole run fails with code 99. (A program's exit code is the number it hands back when it ends; 0 means success, anything else means failure.) CI systems fail the job on any non-zero exit code, so this is all you need to make a pipeline go red.
Stop early when it's clearly failing
For long tests, you can stop the moment a threshold breaks instead of waiting an hour. Write the condition as an object:
thresholds: {
http_req_duration: [
{ threshold: "p(95)<100", abortOnFail: true, delayAbortEval: "10s" },
],
},abortOnFail: true stops the test as soon as the rule is broken; delayAbortEval: "10s" waits 10 seconds first so a slow first few requests during Warm-up don't end the run. §09 uses this to find a server's limit automatically.
A smoke load test on every pull request
Big load tests are too slow for every push, but a small one isn't: 1–5 virtual users for 30 seconds against a freshly started server, with thresholds. It catches "this change made every request 10× slower" before it merges. The script is the same pattern as above, with a small fixed load and generous limits:
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
vus: 2,
duration: "30s",
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<200"], // generous: CI machines are noisy
checks: ["rate>0.99"],
},
};
export default function () {
const res = http.get("http://localhost:8080/api/products");
check(res, { "status is 200": (r) => r.status === 200 });
sleep(0.5);
}And a minimal CI job in GitHub Actions that starts the server and runs it:
name: load-smoke
on: [pull_request]
jobs:
k6:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: "1.26"
- name: Start the server in the background
run: go run . &
- name: Wait until it answers
run: timeout 60 sh -c 'until curl -sf localhost:8080/api/products; do sleep 1; done'
- name: Smoke load test (fails the job if a threshold is crossed)
run: docker run --rm -i --network host grafana/k6 run - < smoke.jsOpen vs closed: how traffic arrives
The Closed workload model is what §04–§06 used: vus: 10 means 10 loops of "send, wait for reply, repeat". The Open workload model instead fixes the Arrival rate: "start 300 new requests every second".
Real traffic to a public website or API is mostly open. Thousands of people you've never met each decide on their own when to click; none of them knows your server is slow right now. Closed traffic exists too — say, 4 batch workers that each process one job at a time — but it's the exception.
See the difference
Same simulated server as §01 (8 slots, ~20 ms each, so a ceiling of ~400/s). Try the closed model with 8, then 16, then 64 users. Then switch to open and try 300/s, then 450/s.
- Closed, many users: throughput is stuck at ~400/s and latency grows in proportion to the number of users (64 users ÷ 400/s ≈ 160 ms), but it never runs away. Only 64 requests can exist at once, so the line can never be longer than 64.
- Open, above capacity: the line grows by ~50 requests every second at 450/s, and latency keeps climbing for as long as the test runs. That's what would happen to real users — and a closed test can't show it.
Now compare how the two models see a freezing server (2 s frozen every 10 s — think of a long garbage-collection pause, or a database lock). Use the two preset buttons, which pick settings below the server's ceiling so the freeze is the only problem: closed with 8 users, then open at 300/s. Look at the "slow" count: in the simulation, about 0.2% of the closed test's requests waited over 500 ms, against nearly half of the open test's. Same server, same freezes. Hold that thought for §08.
Open-model tests in k6: scenarios and executors
To control the arrival rate, use a Scenario with an arrival-rate Executor:
import http from "k6/http";
export const options = {
scenarios: {
browse: { // any name you like
executor: "constant-arrival-rate", // open model: fixed arrivals per time unit
rate: 300, // start 300 iterations…
timeUnit: "1s", // …per second
duration: "1m",
preAllocatedVUs: 50, // VUs to create up front
maxVUs: 500, // k6 may create more, up to this
},
},
};
export default function () {
http.get("http://localhost:8080/api/products"); // no sleep: the rate sets the pace
}The shapes from §03 work in the open model too: the ramping-arrival-rate executor takes the same kind of stages, but each target is a rate (requests per timeUnit) instead of a number of users. §09 uses it.
How many VUs will you need? Little's Law again: VUs busy ≈ arrival rate × latency. At 300/s and 21 ms, that's 300 × 0.021 ≈ 6 busy VUs. But if the server slows to 1 second per request, you'd need 300 × 1 = 300. Set maxVUs high enough for the bad case — that's the case you're testing for.
Real runs: open model against the §04 server
20 seconds at each rate, measured on this machine:
| Asked for | Got | p50 | p95 | dropped |
|---|---|---|---|---|
| 300/s | 299.7/s | 20.7 ms | 25.3 ms | 0 |
| 380/s | 379.7/s | 23.1 ms | 29.6 ms | 0 |
| 420/s | 387.5/s | 475.6 ms | 856.3 ms | 303 |
| 450/s | 387.5/s | 869.5 ms | 1.58 s | 599 |
Compare with the closed table in §05: there, 64 users gave a calm-looking p95 of 173 ms. Here, asking for just 10% more than the server can do gives p95 near 1 second within 20 seconds — and it would keep growing in a longer run.
When k6 can't keep up: dropped iterations
The last column matters. When every VU is busy waiting for slow replies and k6 can't create new ones fast enough (or hits maxVUs), the iterations that were due are skipped and counted as Dropped iterations. In a run where maxVUs was 500, k6 also warned:
level=warning msg="Insufficient VUs, reached 500 active VUs and cannot initialize more" executor=constant-arrival-rate scenario=browseEither message means the test sent less load than you asked for. That's still a useful finding (the server is past its limit), but don't report "it handled 450/s" — it didn't. Check dropped_iterations and vus_max in every arrival-rate summary.
Coordinated omission: when the tool lies
The name (popularized by Gil Tene, author of the HdrHistogram library) describes what happens: the load tool and the server coordinate — without meaning to — to omit the worst measurements from the results.
The same server, measured two ways
The §04 server, changed so that every 10 seconds it freezes for 2 seconds (all requests wait — like a long Pause or a table lock). The change is four lines at the top of the handler:
var t0 = time.Now() // when the server started
func products(w http.ResponseWriter, r *http.Request) {
// NEW: from second 8 to second 10 of every 10 seconds, every request waits.
if e := time.Since(t0) % (10 * time.Second); e >= 8*time.Second {
time.Sleep(10*time.Second - e) // sleep until the freeze is over
}
// … the rest of products() exactly as in §04
}time.Since(t0) % (10 * time.Second) is "how far we are into the current 10-second cycle" (% is the remainder after dividing). Between 8 s and 10 s into a cycle, the handler sleeps until the cycle ends.
Two k6 runs of 30 seconds each against it, measured on this machine:
| Test | Requests | p50 | p90 | p95 | p99 | max |
|---|---|---|---|---|---|---|
| Closed: 1 VU, no sleep | 1,131 | 21.4 ms | 25.4 ms | 25.5 ms | 26.7 ms | 2.01 s |
| Open: 40 requests/s | 1,201 | 22.6 ms | 1.09 s | 1.59 s | 2.01 s | 2.18 s |
Same server, same freezes, similar request counts. One test says p99 is 27 ms; the other says 2 seconds. The server was frozen 20% of the time, so about 20% of the open test's requests arrived during a freeze. Someone arriving at the start of a freeze waits almost 2 s, someone arriving at the end hardly at all, so that slowest 20% is spread from ~0 to 2 s. That's why the open test shows p90 ≈ 1 s (the middle of that spread) and p99 ≈ 2 s.
Why? The closed test has one virtual user. When the server freezes, that user's request is stuck for 2 s, and the user sends nothing else until it returns. So each freeze produces exactly one slow sample, out of ~380 per freeze cycle. The max column caught it; every percentile missed it.
On a timeline
Here's the effect up close: a client that plans to send a request every 50 ms. Compare measuring each request from when it was actually sent with measuring from when it should have been sent:
The fix: measure from the planned time
There are two ways out, and they're the same idea:
- Use an open model (§07). Requests are sent on schedule, so the ones due during the freeze are sent — and they wait, and that wait is measured.
- If you must loop, time each request from when it was supposed to start, not from when you got around to sending it.
Here's the second idea as a tiny load generator — a single loop that plans 20 requests per second and records both numbers. Both versions are complete programs:
package main
import (
"fmt"
"io"
"net/http"
"slices"
"time"
)
func main() {
const url = "http://localhost:8080/api/products"
const rate = 20 // planned: 20 requests per second…
const total = 600 // …for 30 seconds
interval := time.Second / rate // = 50ms between planned sends
var naive, corrected []time.Duration
start := time.Now()
for i := range total {
planned := start.Add(time.Duration(i) * interval)
time.Sleep(time.Until(planned)) // negative = already late: returns at once
sent := time.Now()
get(url)
done := time.Now()
naive = append(naive, done.Sub(sent)) // from when we actually sent it
corrected = append(corrected, done.Sub(planned)) // from when it SHOULD have been sent ← the point
}
fmt.Println("naive: ", summary(naive))
fmt.Println("corrected:", summary(corrected))
}
// get sends one GET request and reads the whole reply.
func get(url string) {
resp, err := http.Get(url)
if err != nil {
return
}
io.Copy(io.Discard, resp.Body) // read the body so the request really finished
resp.Body.Close()
}
// pct returns the p-th percentile (0–100) of ds: sort a copy, pick the value at that rank.
func pct(ds []time.Duration, p int) time.Duration {
s := slices.Clone(ds)
slices.Sort(s)
i := (len(s)*p+99)/100 - 1 // rank p% of the way up, rounded up
return s[max(i, 0)].Round(time.Millisecond)
}
func summary(ds []time.Duration) string {
return fmt.Sprintf("p50=%v p99=%v", pct(ds, 50), pct(ds, 99))
}Against the freezing server, the Go version printed this (measured on this machine; the JS version gave p99 values of 28 ms and 1926 ms):
naive: p50=21ms p99=29ms
corrected: p50=24ms p99=1.923sSame loop, same requests, same server. Only the starting point of the stopwatch changed.
Staff-level notes
- Open-model tools can omit too. If k6 runs out of VUs, it drops the iterations that were due (§07). Those are exactly the requests that would have waited the longest. A run with many
dropped_iterationshas the same blind spot — treat its percentiles as optimistic. - Some tools correct for it. HdrHistogram can fill in the missing samples if you tell it the expected interval, and
wrk2is a version of the classicwrkbenchmark tool rewritten to send at a constant rate. Check what your tool does before trusting its p99. - Your monitoring can have the same blind spot. Server-side latency metrics often start the timer when the handler starts — after the request already waited in a connection queue. Measure as close to the user as you can.
- "Max" is a hint. When the max is 100× the p99, something stalled and most of the stall may be missing from the percentiles.
Finding the knee
Think of a supermarket with 8 checkouts. At quiet times you walk straight to an open one. As it fills up, now and then you wait behind someone. When all 8 are busy nearly all the time, every new shopper waits behind several others — and if shoppers arrive faster than the checkouts can serve them, the line grows without end.
Real numbers from the §04 server
The Knee is easiest to see when you test several fixed arrival rates, one after another, and plot two things for each: how much got done, and how long it took.
Three zones show up in every system:
- Below the knee (up to ~350/s here): throughput equals what you asked for; latency equals "no load" latency. Adding traffic costs nothing.
- At the knee (~380/s): still keeping up, but the tail starts to rise (p95 25 → 30 ms). Requests sometimes find all 8 slots busy and wait a moment.
- Past the knee (400/s and up): throughput is flat at the ceiling (~388/s), and latency jumps from milliseconds to seconds. More traffic only makes the line longer.
Why the bend is so sharp
Requests don't arrive in a perfectly even stream; they come in random little clumps. At 50% Utilization, a clump usually finds free slots. At 95%, almost every request arrives to find all slots busy and has to wait behind the ones already waiting. The waiting time doesn't grow in a straight line with busyness — it grows faster and faster as you approach 100%. Worker pools: why latency explodes near 100% busy shows the curve and the formula.
A breakpoint test finds it automatically
Instead of separate runs, you can ramp the arrival rate steadily and let a threshold stop the test (§06):
import http from "k6/http";
export const options = {
scenarios: {
breakpoint: {
executor: "ramping-arrival-rate",
startRate: 50, // begin at 50 requests per second…
timeUnit: "1s",
preAllocatedVUs: 50,
maxVUs: 1000,
stages: [{ duration: "2m", target: 600 }], // …and climb steadily to 600/s
},
},
thresholds: {
http_req_duration: [
{ threshold: "p(95)<100", abortOnFail: true, delayAbortEval: "10s" },
],
},
};
export default function () {
http.get("http://localhost:8080/api/products");
}stages here lists targets for the rate, not for users: over 2 minutes, go from startRate up to 600 per second. Against the §04 server (measured on this machine):
running (1m20.0s), 0000/0077 VUs, 18557 complete and 77 interrupted iterations
breakpoint ✗ [ 67% ] 0075/0077 VUs 1m20.0s/2m0s 416.61 iters/s
level=error msg="thresholds on metrics 'http_req_duration' were crossed; at least one has abortOnFail enabled, stopping test prematurely"
✗ 'p(95)<100' p(95)=122.74msIt stopped at 1 minute 20 seconds, when the rate was 50 + 550 × (80 ÷ 120) ≈ 417/s (k6 itself shows 416.61 iterations/s) — noticeably past the real knee of ~380/s. Why so late? A threshold is computed over the whole test so far. The first minute of fast requests keeps the overall p95 low, so the slow requests at the end need a while to push it over 100 ms.
Then ask: what was full?
A knee is always caused by one resource running out — the Bottleneck. Here we know it: the pool of 8 slots, 8 ÷ ~20 ms ≈ 400/s. In a real system, find it by watching server-side metrics during the test: which one hits 100% or starts to build a queue right at the knee? CPU? The database Connection pool? A lock? A downstream service?
Fixing it moves the knee. With the pool raised to 16 slots, the same server measured on this machine kept up at 760/s (p95 61 ms) and hit its ceiling at about 776/s — roughly double, as the arithmetic predicts. A higher rate would eventually expose the next bottleneck (CPU, the network, the database itself). Here even k6 started to drop a few iterations at 700/s, because the load generator and the server were sharing the same 4-CPU machine — §11 explains why that's a problem.
Capacity planning: from knee to server count
Back to the bridge: it bends dangerously at 60 tonnes, so the sign says 40. Nobody posts the breaking point as the limit. Capacity planning works the same way: measure where it bends, then plan to stay well below that line.
A worked example
- Safe rate per instance. §09 measured a knee of about 380/s for one instance while p95 stayed under 100 ms. We'll only plan to run each instance at 70% of that: 380 × 0.7 = 266/s. The other 30% is Headroom for random bursts.
- Peak traffic. Your metrics say the busiest minute last month was 2,400 requests/s. (Not the daily average! Traffic at 8 p.m. can easily be several times the 4 a.m. level.)
- Growth. Marketing expects 30% more users before the next review: 2,400 × 1.3 = 3,120/s.
- Instances: 3,120 ÷ 266 = 11.7 → round up to 12.
- Spare: add one so a crash or a Rolling deploy (servers replaced a few at a time) doesn't push the rest over the edge: 13 instances. This is called N+1.
The traps in each input
"Per instance" assumes adding instances adds capacity. Often it doesn't, fully. Ten app servers might share one database, one cache, one rate-limited payment API. Scale the app tier and the bottleneck simply moves there. Before you trust the multiplication, run the test against 2 and then 4 instances behind the Load balancer: if 4 instances don't give close to 4× the throughput, find out what's shared. See Load balancing.
Count the shared resources too. Little's Law tells you how many requests are in flight at once: 3,120 req/s × 0.02 s ≈ 62 requests using a database connection at any moment, on average. If the database allows 100 connections and every instance opens a pool of 20, then 13 instances × 20 = 260 connections — far more than the database accepts. Load tests at the planned scale catch this; spreadsheets don't.
Peaks have peaks. A per-minute peak hides per-second bursts. That's what the headroom is for — and why running at 95% of the knee isn't "efficient", it's fragile.
Auto-scaling isn't instant. Autoscaling adds servers when load rises, but noticing the rise, starting a machine and warming it up can take minutes. Your headroom must carry the traffic during those minutes. A spike test (§03) tells you whether it does.
Testing somewhere that resembles production
Testing a bridge model in a wind tunnel is useful — but only if the model has the same shape, materials and proportions as the real bridge. Here's what has to match, roughly in order of how often it goes wrong.
1. The environment
Run against a staging environment that matches production in shape: the same instance sizes, the same number of database replicas, the same load balancer, the same configuration (connection-pool sizes, timeouts, log levels). A staging database on a tiny machine will become the bottleneck at a tenth of production's traffic and send you chasing the wrong problem.
If staging must be smaller, keep the ratios the same (one app server per database replica, for example) and test one instance's capacity, which you then multiply carefully (§10).
2. The data
A query that takes 2 ms on a table of 1,000 rows can take 2 seconds on 50 million — especially if an index is missing (see Indexes). Load-test against production-sized data: an anonymized copy of production (real data with names, emails and other personal details replaced by fakes), or generated data with realistic sizes and distributions.
Also vary what each virtual user asks for. If every VU requests product #1, it's served from the Cache every time and you're measuring the cache. Real users ask for thousands of different things, and many of those miss the cache. Pick IDs at random from a realistic list.
3. The dependencies
Your service calls a payment provider, an email service, a search cluster. Options:
- Call the real staging versions when they can take the load (and their owners agree).
- Replace them with a fake that answers with realistic delays, not instantly — a fake that responds in 0 ms makes your service look faster than it will ever be. See Test doubles.
- Never send load to a third party's production API.
4. The load generator
The Load generator is a computer too. If it runs out of CPU or network, requests leave late and timings get inflated. Watch it during the test:
- Don't run it on the same machine as the server. They fight over the same CPUs. (§09's 700/s run already showed a few dropped iterations for exactly this reason.)
- Check its CPU stays well below 100%. If not, use a bigger machine or several generators. k6 can split one test across machines; Grafana Cloud k6 (a hosted service) and the k6 operator for Kubernetes (an add-on that runs one test as many k6 copies on a cluster) do this for you.
- Mind the network distance. A generator in the same data centre adds ~1 ms; one across an ocean adds ~100 ms to every request. Neither is "wrong", but know which question you're asking: "how fast is the server?" or "how fast is it for users in Asia?"
5. Time
Run long enough to get past Warm-up and to see slow effects: garbage collection cycles, cache expiry, connection recycling, log rotation. A 30-second test is fine for a smoke test; a capacity measurement wants several minutes at each step; leaks need a soak test of hours (§03).
Testing in production
Sometimes staging can never be realistic enough. Teams then test in production, carefully:
- Off-peak, with a kill switch. Run during quiet hours, with someone watching dashboards and a way to stop the test instantly.
- Marked traffic. Add a header such as
X-Load-Test: 1so test requests are easy to filter out of analytics, skip real payments and emails, and can be blocked at the edge (the load balancer or CDN that receives traffic before your servers do) if something goes wrong. - Shadow traffic. Copy a share of real requests to a new version and throw its answers away. Real traffic shapes, no risk to users.
- Start small. Production tests usually aim at "can we handle 1.5× today's peak?", not "where does it break?" Breakpoint tests belong in staging.
Pitfalls gallery
Reporting the average
"Average response time: 60 ms" can mean everyone waited 60 ms, or 98% waited 20 ms and 2% waited 2 seconds (§02). Always report p50, p95 and p99 with the throughput and error rate. If someone hands you only an average, ask for the percentiles before deciding anything.
Measuring latency with a closed model
"We tested with 200 virtual users and p99 was fine" — but a closed test sends less when the server slows down, and misses stalls entirely (§07–§08). To answer "what will users feel at 500 requests/s?", use an arrival-rate executor and ask for 500/s.
A test that can't fail
Without thresholds, k6 exits with 0 even when 100% of requests fail (§06). Nobody reads the output of a passing CI job. Write your targets as thresholds, so a regression turns the pipeline red.
Testing the cache instead of the system
If every virtual user requests the same URL, every request after the first is a cache hit. Spread requests over realistic IDs — and give them one shared name, or k6 creates a separate entry in its results for each of 10,000 URLs:
export default function () {
// every request: same product, always cached
http.get(`${BASE}/api/products?id=1`);
}export default function () {
const id = Math.floor(Math.random() * 10_000) + 1; // a random product, 1–10,000
http.get(`${BASE}/api/products?id=${id}`, {
tags: { name: "GET /api/products?id=:id" }, // one name for all of them in the results
timeout: "5s", // give up like a real client would (see #8)
});
}10_000 is just 10000; JavaScript allows _ between digits for readability.
Celebrating fast responses that are errors
A 500 or 404 takes almost no work, so a broken service can show amazing latency — §06's broken script had a median of 0.12 ms. Check http_req_failed and your checks before looking at latency, and prefer the { expected_response:true } latency line, which only counts successful responses.
The load generator is the bottleneck
If the machine running k6 is at 100% CPU, requests leave late and timings include the generator's own delays. Run it on a separate machine from the server, watch its CPU, and split the load across several generators when one isn't enough (§11).
Ignoring dropped iterations
An arrival-rate test that reports dropped_iterations (or warns "Insufficient VUs") didn't send what you asked for — and the requests it skipped are the ones that would have waited longest (§07–§08). Raise maxVUs, or report the result as "couldn't sustain the rate", not as a pass.
Waiting longer than any real client would
k6 waits up to 60 seconds for a response by default. Real browsers, mobile apps and upstream services usually give up much sooner — and often retry, adding more load. Set a realistic timeout per request so slow responses show up as errors, the way users would experience them.
Judging the first minute
Empty caches, connections still opening, a just-started runtime: the first minute is slower than steady state, and a 30-second test is mostly warm-up (§03). Ramp up, run long enough, and focus on the steady part. On the other hand, if your real traffic often hits cold servers (after deploys or scale-ups), test that on purpose.
Load-testing something you don't own
Pointing k6 at a third-party API, a partner's server or a shared staging environment without telling anyone looks exactly like an attack. Get permission, tell the on-call engineers, and fake third-party dependencies with realistic delays (§11). Rate limiters will also distort your results — see Rate limiting.
Check yourself
Predict the answer before you click. Each explanation points back to the section that covers it.
Each VU does http.get(...) then sleep(1). The endpoint answers in about 50 ms. Roughly how many requests per second does the server see?
The server freezes for 2 s every 10 s. Test A uses 1 VU looping with no sleep. Test B sends 40 requests per second on a fixed schedule. Which reports the higher p99?
In CI, k6 run load.js finishes and the job fails with exit code 99.
An arrival-rate test asks for 450 requests/s. http_reqs shows 387.5/s, dropped_iterations is 599, and p95 is 1.58 s. What's the best reading?
Two load generators each report a p95. Generator 1: 40 ms. Generator 2: 400 ms. They sent the same number of requests. What's the p95 across both?
One instance's knee is 400 req/s. You want to run at ≤ 75% of the knee, expect a peak of 2,000 req/s, and keep one spare. How many instances?
Cheat sheet
| I want to… | In k6 |
|---|---|
| Check the script works | vus: 1, duration: "1m" plus thresholds (smoke test) |
| N users looping (closed) | vus: N, duration: "5m", or stages: [{ duration, target }] to ramp |
| X requests per second (open) | executor: "constant-arrival-rate", rate: X, timeUnit: "1s", maxVUs high enough |
| Ramp the rate | executor: "ramping-arrival-rate" with stages that target rates |
| Fail the run on a target | thresholds: { http_req_duration: ["p(95)<100"], http_req_failed: ["rate<0.01"] } → exit code 99 |
| Stop early when it's clearly failing | { threshold: "p(95)<100", abortOnFail: true, delayAbortEval: "10s" } |
| See p99 in the summary | k6 run --summary-trend-stats "med,p(95),p(99),max" script.js |
| Keep every measurement | k6 run --out json=results.json script.js |
| Pass a value in | k6 run -e RATE=300 script.js, read with __ENV.RATE |
| Group many URLs under one name | http.get(url, { tags: { name: "GET /items/:id" } }) |
| Rule of thumb | Formula |
|---|---|
| Closed-model throughput | VUs ÷ (latency + think time) |
| VUs needed for an open test | arrival rate × worst-case latency |
| Ceiling of a pool | slots ÷ time each request holds a slot |
| In-flight requests (Little's Law) | rate × latency |