Blog/Quality Engineering

Load testing for payday: model arrivals, not users

Most load tests model a fixed number of users looping. Payday traffic arrives at a rate instead, and that difference is what breaks systems at the worst possible minute.

A load test with two thousand virtual users passes. Then salaries land, the real crowd shows up, and the service falls over at what monitoring reports as a lower load than the test applied. The test was right about the number of people. It was wrong about what a person does when the system slows down, and that single wrong assumption is why the green result did not survive contact with the real peak.

In Saudi systems this matters more than in most places, because the peaks are sharp and predictable. Salaries land on a known rhythm each month, and apps that are quiet in the early afternoon meet a wall of logins and transfers within minutes. Wallets, banking apps and e-commerce all feel it, and seasonal sales stack another spike on top. A test that does not reproduce the shape of that arrival is testing a different system than the one your customers use.

In short

  • Closed-model tests fix the number of users; when the system slows, the virtual users slow with it, so applied load drops exactly when you need it highest.
  • Payday traffic is an open model: requests arrive at a rate that does not care how slow you are.
  • k6’s ramping-arrival-rate executor holds a fixed arrival rate and allocates VUs to keep it.
  • Watch dropped_iterations. If it climbs, k6 ran out of VUs and never applied the load you think it did.
  • Build the profile from your own access logs: peak-minute rate, real endpoint mix, a pool of accounts, honest think time.
  • A pass is latency percentiles and error rate held at the target rate, plus headroom and a soak, not a single green check.

Closed versus open, in plain terms

The default way to write a load test is to spin up N virtual users, each in a loop: send a request, wait a moment, send the next. k6 calls this a closed model. The number of users in the system is fixed at N, and here is the catch that ruins peak tests: a looping user only starts their next request after the previous response comes back. When the system slows down, the virtual users slow down with it. They wait politely. The load you apply drops at exactly the moment the system is struggling, which is the opposite of what real traffic does.

Payday traffic follows an open model. Requests arrive at a rate set by how many people decide to open the app, and that rate is indifferent to your response time. If ten thousand people check their balance in a minute, they arrive whether each response takes two hundred milliseconds or four seconds. When responses slow, requests pile up instead of waiting their turn, and the queue is where a system tips from slow to down.

The consequence for testing is blunt. A closed-model test with a user count tuned to average load will under-test the peak, because the model cannot apply pressure faster than the system accepts it. To test payday you hold the arrival rate fixed and let response time be the thing that varies.

Autoscaling makes the distinction matter even more. A closed-model test ramps gently enough that the platform scales up in step and everything looks healthy, because the load never arrives faster than new instances can boot. Real payday traffic does not wait for your instances to boot. The arrival rate spikes in seconds, requests queue against the capacity you have at that instant, and whether you survive depends on cold-start time and connection limits in the minute or two before new capacity is ready. An open-model test is the reliable way to put pressure on that window, and that window is where systems fail first.

Testing by arrival rate in k6

k6 supports this directly with the ramping-arrival-rate executor. You specify an arrival rate in iterations per time unit, k6 holds that rate regardless of response time, and it allocates virtual users as needed to keep it. The executor takes startRate, timeUnit, preAllocatedVUs, maxVUs and stages.

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

export const options = {
  scenarios: {
    payday: {
      executor: 'ramping-arrival-rate',
      startRate: 50,              // iterations per second; one request each here
      timeUnit: '1s',
      preAllocatedVUs: 200,       // VUs ready before the test
      maxVUs: 2000,               // ceiling k6 may grow to, to hold the rate
      stages: [
        { target: 50,   duration: '1m' },   // warm up
        { target: 1200, duration: '2m' },   // the login burst as salaries land
        { target: 1200, duration: '5m' },   // hold the peak
        { target: 50,   duration: '2m' },   // back down
      ],
    },
  },
  thresholds: {
    http_req_failed:   ['rate<0.01'],         // under 1% errors
    http_req_duration: ['p(95)<800'],         // 95th percentile under 800ms
    dropped_iterations: ['count<1'],          // k6 kept up with the target rate
  },
};

export default function () {
  const res = http.get('https://staging.example.test/api/balance');
  check(res, { 'status 200': (r) => r.status === 200 });
}

Watch dropped_iterations as closely as the latency numbers. The metric counts iterations k6 could not start because no VU was free, usually because it hit maxVUs. When it climbs, it is a flaw in your load generator, not a result about your system. A test that reports great latency while silently dropping a third of its intended load is telling you the system was never actually pushed to the target rate. Raise maxVUs, give k6 more preAllocatedVUs to start, or run the generator from more than one machine, then run it again until dropped iterations stay at zero.

The thresholds block is what turns the run into a pass or fail. http_req_failed: ['rate<0.01'] fails the test if more than one percent of requests error, and http_req_duration: ['p(95)<800'] fails it if the 95th percentile crosses 800 milliseconds. Set these to the numbers your product owner has actually committed to, not round defaults, because a threshold is the line between a release you sign off and one you hold back.

Build the profile from your access logs

A number pulled from the air produces a test that passes or fails for reasons unrelated to reality. The arrival rate, the endpoint mix, the data variety and the think time all come out of last month’s logs.

  1. 01Pick the peak minutefind the single busiest minute, not the busiest hour
  2. 02Convert to arrival raterequests in that minute divided by 60 gives requests per second
  3. 03Take the endpoint mixweight the scenarios by the real share of each endpoint
  4. 04Vary the datapull a pool of accounts and IDs so the test runs past the cache
  5. 05Add think timemodel the pause a real user takes between actions
Building a load profile from last month’s access logs

Use the peak minute, not the peak hour. Averaged over an hour a payday spike disappears into a gentle bump; the minute the doors open is the minute that breaks things. Take the endpoint mix from the same window, because real users do not hammer one URL. In the opening burst the traffic is mostly logins and balance checks, with transfers following once people are in, and a flat mix of everything misses the login stampede that actually falls over first.

Profile inputWhere it comes fromWhy it matters
Peak arrival rate (req/s)busiest minute in the access logssets startRate and the stage targets
Endpoint mixpath frequency during the peak windowlogin and balance dominate the opening burst
Account poolanonymized user and account IDsstops you load-testing one cached row
Think timegap between a user’s requests in a sessionmodels behavior once a user is inside
Peak durationhow long the spike lastssets the length of the hold stage

Two data problems inflate your pass without showing up as an error. If every virtual user hits the same account, you are load-testing one row and its cache, so give the test a pool of accounts drawn from the logs. And caching flatters you in general: a test that reads the same handful of records runs warm in a way cold traffic does not, so include the variety that defeats the cache your production numbers depend on. Keep think time where it belongs, as the pause each user takes between actions. The arrival rate models how fast people show up; think time models what each one does once inside.

Note

The numbers in the script and the chart below are illustrative, not measurements of any real Saudi service. Build your own rates from your own logs. A target borrowed from someone else’s system tests their traffic shape, not yours.

What the test should hold, and the chart to read

The stages in the script describe a shape: a short warm-up, a two-minute ramp into the login burst, a five-minute hold at the peak, and a ramp down. Plotting the target rate across those phases makes the profile legible to anyone reviewing the test.

Warm up50 req/s
Login burst1200 req/s
Hold the peak1200 req/s
Ramp down50 req/s
Target arrival rate across the test phases (illustrative) Source: Illustrative numbers

A single green tick tells you almost nothing. A meaningful result is the latency percentiles and error rate held at the target arrival rate: p95 and p99 under your thresholds, errors under one percent, while the rate is sustained across the whole hold stage rather than touched for a second. The hold stage is where you learn whether the system degrades gracefully or collapses, so read the percentiles across all five minutes, not the best moment in them.

Report percentiles, never averages, and know what each one is telling you. An average hides the tail, and the tail is where customers give up: a p50 of 180 milliseconds with a p99 of nine seconds means one request in a hundred is unusable, and on payday one in a hundred is thousands of people. p95 is the number most teams commit to in an SLA; p99 is the one that predicts the support tickets. Watch both as the arrival rate climbs, because a p99 that stays flat until the peak and then goes vertical is the signature of a queue filling, and the rate at which it turns vertical is your real capacity ceiling in requests per second.

Headroom and soak

The ramp into the peak deserves as much thought as the peak itself. Salaries do not arrive as a smooth two-minute slope; the real curve is closer to a step, a quiet morning and then a near-vertical rise the moment deposits post. Make the ramp stage short and steep rather than long and gentle, because a gentle ramp gives autoscaling and caches time to warm that the real event will not give them. If your monitoring shows the historical rise happening inside a minute, model it inside a minute, or the test hands the system a warm-up the day itself withholds.

Two runs beyond the basic profile earn their place. Add a headroom run above your expected peak, because the honest question is not whether you survive the forecast but how far past it you get before you break. Knowing you have, say, forty percent of headroom above the worst payday you have seen is the difference between a calm on-call rotation and a surprise. And add a soak, the same load held for an hour or more, because a certain class of failure only appears under sustained load: memory creeping up until a process is killed, a connection pool slowly exhausting, a log disk filling. A five-minute test never reaches those, and they are exactly the failures that take a service down two hours into a busy day.

Dependencies you do not own

Your payment gateway’s sandbox has its own rate limits, usually far below production. Hammer it in a load test and you measure the sandbox’s throttle, not your capacity, and you may get your access cut for the afternoon. Stub those downstream calls, or agree explicit test limits with the provider in advance, and be clear in the results about which dependencies were real and which were stubbed. A load test that silently mocked the slowest hop carries an asterisk that leadership needs to see, because it changes what the result means.

The dependency you do own is often the one that breaks first, and it is usually the database connection pool. Under the open model, slow queries hold connections longer, the pool drains, and new requests wait for a connection that never frees, which turns a mild slowdown into a cliff. A load test that uses a realistic account pool and realistic query variety will find that cliff. One that reads the same cached row will sail past it and report that everything is fine, right up to the morning it is not.

A test-day runbook

Run the peak test like a small operation, with the same discipline you would want on the real day.

  • Confirm the target rate and endpoint mix match this month’s logs, not last quarter’s.
  • Point the test at a production-like environment, not a shared one under other load.
  • Stub or rate-agree every third-party dependency, and write down which were stubbed.
  • Start monitoring on the system under test before the generator starts.
  • Run the generator from enough machines that dropped_iterations stays at zero.
  • Capture p95, p99, error rate and dropped_iterations for the whole run, ramps included.
  • Keep the raw results. A screenshot of a green check is not a record you can defend.

Reporting results to leadership

The report that lands is short and honest. Lead with the one sentence that answers the question they are actually asking: at the target arrival rate of the busiest payday minute, the system held p95 under 800 milliseconds and errors under one percent for the full hold, with the generator never dropping an iteration. Then give the headroom factor, so they know how far past the forecast the system stands, and name the dependencies that were stubbed, so nobody mistakes a partial test for a full one. If the test failed a threshold, say which one and at what rate it broke, because “it fell over at 1,500 requests per second on the balance endpoint” is a number an engineering team can act on, while “the load test failed” sends everyone back to the logs.

Pull last month’s peak-minute request rate and endpoint mix from your access logs before anything else. That one query turns the whole exercise from a guess into a test of the day you actually have to survive.

Sources

  1. k6 docs: open and closed models, Grafana Labs, 2025.
  2. k6 docs: ramping arrival rate, Grafana Labs, 2025.
  3. k6 docs: dropped iterations, Grafana Labs, 2025.
  4. k6 docs: thresholds, Grafana Labs, 2025.
inX
QE

Written by

EhsanLab Quality EngineeringTest automation and performance

Our test automation engineers build and maintain regression suites inside client pipelines, from Playwright and Selenium to Appium on real devices.

EhsanLab

Want us to look at your systems?

Tell us what you are building and what worries you about it. A specialist replies within one business day.