DEV Community

Elvin Aghalarov
Elvin Aghalarov

Posted on Originally published at hoofbeat.dev

How many users can your server actually handle? Measure it in an hour

"How many users can this server handle?" comes up before every launch, and the usual answers are guesses: "a 2 vCPU box should do about 1,000 users."

That number means nothing. 1,000 people reading articles and 1,000 people checking out are completely different loads. But you can get a real number for your app in about an hour, and the method is the same for Laravel, Rails, Django, Node or Go.

Step 0: decide what "handle" means

Every server "handles" any load: it just gets slower until people leave. So pick limits first:

  • Latency: 95% of requests answer within 500 ms (p95 < 500 ms)
  • Errors: fewer than 1% fail
  • Stability: numbers stay flat for minutes, not just the first 30 seconds

Now capacity is a single number: the highest load at which all three still hold.

Why rules of thumb fail

Users aren't requests. A reader clicks every ~45 s; a shopper in checkout fires several API calls every few seconds:

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

Same "10,000 users", 18× the load.

The bottleneck is rarely CPU. It's usually:

  • slow queries, N+1s, missing indexes, row locks
  • a pool that's too small: PHP-FPM max_children, Puma threads, Gunicorn workers, DB connections
  • an external API without a timeout holding workers hostage

Caching changes everything. Test what can't be cached: login, search, cart, dashboards.

Step 1: run a step test

Increase load in stages and hold each long enough to settle:

Step Arrival rate Hold
1 10 users/s 2 min
2 25 users/s 2 min
3 50 users/s 2 min
4 100 users/s 2 min
5 200 users/s 2 min

A few rules that make the result honest:

  • Each virtual user walks a real journey: home → search → product → login → add to cart.
  • Add think time (2–5 s) between steps, like a human.
  • Use many test accounts, not one. Otherwise you're benchmarking one cached DB row.
  • Use an arrival rate (open model), not a fixed pool of looping users (closed model). With a fixed pool, users wait when the server slows down and send fewer requests, so the load backs off exactly when things get bad. Real visitors don't coordinate with your server.
  • Test the production setup. Not php artisan serve, not npm run dev, not debug mode.

You can do this with k6, Gatling, Locust, Artillery, or a hosted tool. The method matters more than the tool.

Step 2: find the knee

Plot p95 latency and error rate against the arrival rate. You'll almost always see three zones:

  1. Flat: latency barely moves. Headroom.
  2. The knee: latency climbs faster than the load. Something is queueing.
  3. Collapse: timeouts, 502s, 503s.

Your capacity is the last step before the knee where your limits still hold.

If p95 is 180 ms at 25 users/s, 240 ms at 50, then 1.4 s at 100, your answer is ~50 users arriving per second, not 100.

Translate it for the business: with 50 new visitors/s and an average visit of 4 minutes, that's about 12,000 people on the site at once (50 × 240 s). That's Little's Law, and it's the sanity check for everything above:

in-flight requests = requests per second × avg response time
Enter fullscreen mode Exit fullscreen mode

At 500 RPS and 200 ms you have ~100 requests in flight. Let response time drift to 2 s and it's 1,000, which is more than most worker pools or DB connection limits. That's how a "small slowdown" becomes an outage.

Step 3: look at what bent at the knee

Put your server metrics on the same timeline as the test:

  • Idle CPU + rising latency → a pool is exhausted (workers, threads, DB connections)
  • CPU pegged → hashing at login, huge JSON serialization, rendering without caches
  • One endpoint slow, others flat → that endpoint's query: EXPLAIN ANALYZE it
  • too many connections errors → size the pool or add PgBouncer

Fix one thing, rerun the identical test, compare. Change three things at once and you'll never know which one helped.

TL;DR

  1. Define "handle" as p95, error rate and stability limits.
  2. Step test with real journeys, think time, many accounts and an arrival rate.
  3. Capacity = last step before the latency knee.
  4. Correlate the knee with server metrics, fix one bottleneck, repeat.

I build Hoofbeat, a load-testing tool where you can describe this kind of test in plain words and get the capacity verdict at the end. But the method above works with any tool. The original version of this post, plus guides on soak testing and finding bottlenecks, is on the Hoofbeat blog.

Top comments (0)