A requirement lands on your desk: "The system must support 10,000 concurrent users."
It sounds precise. It isn't. Depending on what those users do, it means anything from ~670 to ~12,000 requests per second. And the way most people then load test it hides the exact moment the system starts to fail.
Let's fix both.
Three numbers that get mixed up
- Concurrent users: people with your app open right now. Most are reading, typing or thinking, not sending requests.
- Requests per second (RPS): what your servers actually process. This is what burns CPU, DB time and connections.
- Arrival rate: new users or sessions starting per second. This is how real traffic shows up.
Your servers never feel "users". They feel requests.
Converting users → requests
RPS = concurrent users × requests per user per minute ÷ 60
The "requests per user per minute" part is where everything hides:
| News site | Checkout flow | |
|---|---|---|
| Time between clicks | ~45 s | ~5 s |
| API calls per click | 3 | 6 |
| Requests / user / min | 4 | 72 |
| RPS at 10,000 users | ~670 | ~12,000 |
Before you test anything, pull two numbers from your analytics or access logs: how often users act and how many requests each action costs. Without them, "10,000 users" is not a requirement, it's a mood.
The bigger mistake: closed vs open load models
Load tools generate traffic in one of two ways, and they behave very differently when your system slows down.
Closed model: a fixed pool of virtual users
Each virtual user loops: send request → wait for the response → think → repeat.
// k6: 100 virtual users looping for 5 minutes (closed model)
export const options = {
scenarios: {
closed: { executor: 'constant-vus', vus: 100, duration: '5m' },
},
};
Now imagine the server slows from 200 ms to 2 s per response. Every virtual user waits 10× longer before its next request, so the load drops by itself, right when the system is struggling. Your graphs show a slightly slower but "stable" system.
Real users don't do that. The person clicking "Buy" on their phone has no idea your server is having a bad minute.
This effect is called coordinated omission: the load generator coordinates with the system under test and silently omits the requests that would have hurt the most.
Open model: users arrive at a fixed rate
New users arrive at a set rate whether or not earlier ones are done:
// k6: 50 new iterations per second, regardless of response time (open model)
export const options = {
scenarios: {
open: {
executor: 'constant-arrival-rate',
rate: 50,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 200,
maxVUs: 2000,
},
},
};
When the server slows down, work piles up, exactly as it would in production. That's the behaviour you need to see before launch.
Rule of thumb:
- Public website or API → open model (arrival rate).
- Genuinely fixed number of clients (200 internal workers, a fleet of devices polling every minute) → closed model is fine.
Little's Law: why a small slowdown becomes an outage
For any stable system:
requests in flight = RPS × average response time
| RPS | Avg response | In flight |
|---|---|---|
| 500 | 200 ms | 100 |
| 500 | 800 ms | 400 |
| 500 | 2 s | 1,000 |
Same traffic, 10× the concurrent work. If your PHP-FPM pool has 100 workers, or Postgres allows 100 connections, the third row doesn't run slower: requests queue, latency grows further, more work piles up, and you're in a death spiral.
That's also why an open-model test is the one that finds your real limit: it's the only one where the in-flight count can grow like it does in production.
Setting the load for your test
- Take the busiest hour from analytics, for example 54,000 sessions.
- Convert it to an arrival rate: 54,000 ÷ 3,600 = 15 users/s.
- Model the journey with realistic think time between steps.
- Test at 2–3× the peak: 30–45 users/s.
- Watch p95/p99 and error rate per step, not just averages.
TL;DR
- "Concurrent users" is not a load. Convert it to requests per second with real usage data.
- Closed-model tests (fixed looping users) back off when your system struggles, so the results look better than reality.
- For public traffic, use an arrival rate (open model).
- Little's Law explains why a 4× slowdown means 4× the in-flight work, and why pools run out.
I build Hoofbeat, a load-testing tool that supports both models (arrivals per second or a fixed number of users) and reports the requests per second actually achieved per step. The original post is on the Hoofbeat blog, along with how many users can my server handle?
Top comments (3)
The closed model backing off right when the server struggles is such a sneaky trap. The test looks stable only because the load generator is being polite. “10,000 users is not a requirement, it’s a mood” made me laugh, and the Little’s Law table explains pool exhaustion better than most docs I’ve read. Saving this for my next load test.
Good to know! I would love to hear a feedback!
I re-ran your two tables and they close: 10,000 x 4 / 60 = 667 and 10,000 x 72 / 60 = 12,000, and the Little's law rows (100, 400, 1,000 in flight) are right too.
One place the open-model example can quietly bring coordinated omission back: the rate is in iterations, so what an iteration contains decides the VU count. Little's law applies to the users as well. 10,000 concurrent users who each stay 10 minutes means about 16.7 sessions starting per second, and each of those holds a VU for the whole session, so you need roughly 10,000 VUs, not the 200 preallocated or 2,000 max in the snippet. When k6 runs out of VUs it does not fail loudly, it skips iterations and counts them in dropped_iterations, so the arrival rate you configured is not the one you delivered, and it happens exactly when the server is slow and sessions last longer. Worth adding a threshold on dropped_iterations (for example count==0) next to the latency thresholds, so a test that shed its own load fails instead of passing.
Do you model sessions as one iteration, or one iteration per request?