Or press ESC to close.

Registered Is Not Concurrent: How to Pick Concurrent Users for a Load Test

Oct 18th 2026 8 min read
easy
javascriptES6
k62.3.0
performance
load
strategy

"How many concurrent users should we test with?" is one of the first questions in any load test, and one of the easiest to answer badly. Teams pick a round number like 1,000 because it sounds serious, or divide the user base by some factor, and end up with a test that proves nothing about the busiest hour of a real day. This post shows a repeatable way to turn a traffic estimate into a concurrent user number, even when there is very little historical data, and then turns that number into a k6 scenario you can run.

Registered Is Not Concurrent

When a customer says "we have 50,000 users", they are describing a database table, not a load. Most of those accounts are idle at any given moment. What matters is how many people are using the application at the same time during the busiest stretch of the day. Getting there is a funnel, and every step is a question you can put to the customer, the product owner, or your analytics:

Step Question Example
Registered users How many accounts exist? 50,000
Daily active users How many show up on a typical day? 15,000 (30%)
Sessions per day How many visits does each active user make? 1 each, so 15,000
Peak-hour share What share of the day's sessions start in the busiest hour? 12%, so 1,800
Session duration How long does one visit last, start to finish? 6 minutes

These numbers are illustrative, and we will keep using them for the rest of the post. Notice that the funnel counts sessions, not people. A user who opens the application three times a day is counted three times, because each visit is what actually puts load on the system.

Little's Law Does the Heavy Lifting

The funnel gives us sessions per hour, but we want sessions happening at once. Little's Law connects the two. For any stable system, the average number of things inside it equals the rate at which they arrive multiplied by how long each one stays:

concurrent users = session arrival rate × average session duration

Our peak hour has 1,800 sessions, which is 0.5 new sessions per second, and each session lasts 360 seconds. So 0.5 × 360 gives 180 concurrent users. That single multiplication settles the "average, peak, or active sessions" debate, because it uses all three: the arrival rate comes from the peak, the sessions come from the funnel, and the duration turns arrivals into overlap.

Two cautions apply. The duration has to be the full time a user stays, including the idle gaps between clicks, not the time it takes to complete one task. And the law describes a steady state, so it is valid for the plateau of a test, not for the first few minutes of ramp-up.

Size for the Peak, Not the Average

It is tempting to use the daily average because it is easy to compute. Spread the same 15,000 sessions evenly across 24 hours and Little's Law gives about 62 concurrent users (15,000 × 360 / 86,400). The busiest hour holds nearly three times that, so a test sized on the average would pass comfortably and say nothing about the hour that matters.

The peak-hour share is the input worth defending. A B2B tool used during office hours has a much sharper peak than a consumer app used around the clock. Once you have the number, add margin. A test at exactly 1x only proves the system survives today's busiest hour, so plan runs at 1x, 1.5x and 2x to cover growth, a marketing campaign, or an unusually bad day. For our example that is 180, 270, and 360 concurrent users. Which multiple acts as the release gate and which one is for finding the limit is a team decision, but make it before the test, not after.

Concurrent Users Are Not Requests per Second

A concurrent user is only as demanding as what they do while they are there. Say a session makes about 30 requests over its 360 seconds. That is one request every 12 seconds per user, so 180 users generate 180 / 12 = 15 requests per second. The same 180 virtual users with no pauses between requests can easily produce hundreds or thousands of requests per second, which tests a completely different and unrealistic system. Right user count with no think time is the most common sizing mistake, so derive the pause from the same session numbers (duration divided by requests) and compare the resulting request rate with what your monitoring says production sees.

The second decision is how the load is applied. A closed model keeps a fixed number of virtual users looping, and each waits for its response before moving on, so when the application slows down the load drops with it and hides the problem. An open model starts new sessions at a fixed arrival rate no matter how the application is doing, which is how real users behave. Little's Law already gave us an arrival rate, so an open model is the natural fit.

When You Have No Historical Data

Most first load tests start without good numbers. The funnel still works, you just fill it with informed assumptions instead of measurements:

The goal is not a perfect number. It is a number where every input is visible, so anyone can challenge an assumption and you can update the test instead of arguing about it.

Turning the Number into a k6 Scenario

k6's constant-arrival-rate executor is the open model we need. It starts new iterations at a fixed rate, and we make each iteration one complete user session: 30 requests with a 12 second average think time. We will build the script in four small pieces, starting with the numbers it is derived from.

The two constants come straight from the funnel. 1,800 sessions in the peak hour is 30 new sessions per minute, and 30 requests spread over a 360 second session is one request every 12 seconds. BASE_URL and LOAD_FACTOR are read from environment variables, so the same script can target another environment or run at 1.5x or 2x without editing it:

                
import http from 'k6/http';
import { check, sleep } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
const LOAD_FACTOR = Number(__ENV.LOAD_FACTOR || 1);

const SESSIONS_PER_MINUTE = 30; // 1,800 sessions in the peak hour
const REQUESTS_PER_SESSION = 30; // about one every 12 seconds
                

Next comes the scenario. rate and timeUnit tell k6 to start that many new sessions every minute, scaled by the load factor, and duration is how long to keep that up. preAllocatedVUs is set a little above the 180 we calculated so k6 does not have to create users in the middle of the run, and maxVUs is the ceiling it may grow to. Because one session lasts six minutes, the run needs to be much longer than that to spend any time at the plateau, so 20 minutes is a sensible minimum here:

                
export const options = {
  scenarios: {
    peak_hour: {
      executor: 'constant-arrival-rate',
      rate: Math.round(SESSIONS_PER_MINUTE * LOAD_FACTOR),
      timeUnit: '1m',
      duration: '20m',
      preAllocatedVUs: Math.ceil(200 * LOAD_FACTOR),
      maxVUs: Math.ceil(400 * LOAD_FACTOR),
    },
  },
};
                

Thresholds turn the run into a pass or fail result. The first two are the usual error rate and 95th percentile latency limits. The dropped_iterations threshold fails the test if k6 could not start sessions on schedule, which happens when sessions take so long that the VU pool runs dry. It sits next to the scenario inside the same options object:

                
export const options = {
  scenarios: {
    // peak_hour scenario from above
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<500'],
    dropped_iterations: ['count==0'],
  },
};
                

Finally, the default function is one complete user session. It sends 30 requests, checks each response, and sleeps between them for a random 8 to 16 seconds, which averages the 12 seconds of think time we derived earlier:

                
export default function () {
  for (let i = 0; i < REQUESTS_PER_SESSION; i++) {
    const res = http.get(`${BASE_URL}/api/items`);
    check(res, { 'status is 200': (r) => r.status === 200 });
    sleep(8 + Math.random() * 8); // think time, averages 12 seconds
  }
}
                

Put the four pieces together in one file, here called peak-hour.js, and the load factor is all you change between runs. To rerun at 1.5x:

                
k6 run -e BASE_URL=https://staging.example.com -e LOAD_FACTOR=1.5 peak-hour.js
                

We ran a shortened 10-minute version against a local stub server to check the math. New sessions arrive every two seconds, so the user count climbs steadily and then levels off right where Little's Law said it would:

running (02m30.0s), 075/200 VUs, 0 complete and 0 interrupted iterations
running (05m30.0s), 165/200 VUs, 0 complete and 0 interrupted iterations
running (06m30.0s), 184/200 VUs, 11 complete and 0 interrupted iterations
running (08m30.0s), 182/200 VUs, 73 complete and 0 interrupted iterations
running (09m30.0s), 179/200 VUs, 106 complete and 0 interrupted iterations

iteration_duration  avg=6m3s
dropped_iterations  0
vus  max=187

Once the first sessions finish at around the six-minute mark, new arrivals and departures cancel out and the active users hover around 180, with a peak of 187 from random variation in the think time. The average iteration lasted 6 minutes and 3 seconds, close to the 360 seconds we designed, and no iterations were dropped. If your own run shows a plateau far from the number you calculated, the session duration is the first thing to check.

Conclusion

A concurrent user count is not a guess or a round number. It falls out of a short chain: registered users become daily sessions, the peak-hour share gives an arrival rate, and Little's Law turns that rate into the users present at once. Give those users realistic think time, apply them as an open model, and test at a margin above the peak. When the inputs are assumptions, write them down, and the next person can improve the test instead of having to start over.

The full code example from this post is available on our GitHub repository.