DEV Community

Elvin Aghalarov
Elvin Aghalarov

Posted on Originally published at hoofbeat.dev

Concurrent users vs requests per second: the load test mistake that makes results look too good

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
Enter fullscreen mode Exit fullscreen mode

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' },
  },
};
Enter fullscreen mode Exit fullscreen mode

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,
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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?

Top comments (3)

Collapse
 
anas_issath profile image
Anas Issath •

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.

Collapse
 
elvinaqalarov99 profile image
Elvin Aghalarov •

Good to know! I would love to hear a feedback!

Collapse
 
arhancanli profile image
Arhan Canli •

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?