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

  1. Take the busiest hour from analytics, for example 54,000 sessions.
  2. Convert it to an arrival rate: 54,000 ÷ 3,600 = 15 users/s.
  3. Model the journey with realistic think time between steps.
  4. Test at 2–3× the peak: 30–45 users/s.
  5. 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?