Load Test an API with Realistic Multi-Step Flows

Load test an API with realistic user flows instead of single endpoints: the virtual user arithmetic, the ramp that finds the ceiling, and how to read the run.

Mustafa BayramogluMustafa BayramogluAbstract illustration of one line multiplying into hundreds converging on a point

To load test an API with realistic user flows instead of single endpoints, script each journey as an ordered sequence that one virtual user runs from start to finish, carrying the token and the IDs it picks up along the way, then run a weighted mix of those journeys at the service. The virtual user count is arithmetic, not a guess: target request rate multiplied by response time plus think time. Ramp that load in stages until request rate flattens while latency climbs, which is where the API’s ceiling sits. Then read the result at the percentiles, not the averages.

The procedure, in six steps

Everything below is the long form of these six steps. Run them in order.

  1. Write the journeys down. Pull the real sequences out of traffic logs or APM data and turn each one into an ordered list of calls: authenticate, create, read, update. Weight each journey by the share of traffic it actually carries, the way one worked model gives a buyer journey 35 percent.
  2. Pick a target request rate. Peak RPS is the requests served in your busiest hour divided by 3600 seconds.
  3. Convert the rate into a head count. Multiply the target rate by response time plus think time.
  4. Give every virtual user its own data. One row per user from a feeder file, one login per user from a pool the size of your peak count.
  5. Ramp in stages. A smoke run of 1 to 5 virtual users proves the script first, then a baseline, then the peak target, then a breakpoint run that deliberately pushes past it.
  6. Read percentiles, not averages.

Steps 2 and 3 are the pair most guides skip. The beginner’s guide that ranks first for this question gets as far as a section headed “Model the workload” and leaves the reader to pick the numbers, and the Postman walkthrough further down the same page asks how response times change when multiple users send requests at the same time. The arithmetic is short, and the rest of this article works it out. Steps 1 and 4 are the ones that decide whether the result describes your service at all, because a journey with the wrong ordering or a fleet of users sharing one login measures something other than the API.

Size the test: the virtual user arithmetic

Two numbers stand between a journey script and a load test, and both of them are arithmetic, not judgement.

Start with the rate you need to hit. Peak RPS is the number of requests served during the busiest hour divided by 3600 seconds. An API that handles 180,000 requests in its peak hour, divided by 3600, is a 50 RPS target.

You need one more input before the formula works: the average response time of the journey as it stands today. That comes from a baseline run or from APM data, and it is the reason the smoke and baseline runs sit below the peak run on the ladder. Guessing it produces a virtual user count that is wrong by the same factor the guess was wrong by.

Now convert that rate into a head count. A virtual user is not a person: it is a loop that runs your journey over and over, and one of them can generate far more traffic than any human. The formula, written out at length in the same team’s post on calculating virtual users over at dev.tools, is target RPS multiplied by response time plus think time.

Run it twice against that same 50 RPS target.

A machine-to-machine API, no think time. Response time 100 ms, think time zero. 50 × (0.1 + 0) gives 5. Five virtual users looping continuously carry the whole 50 RPS.

A human-paced flow. Response time 200 ms, think time 2 seconds while somebody reads the screen. 50 × (0.2 + 2) gives 110. The same 50 RPS now needs 110 virtual users, because most of them are pausing at any given moment.

Same API, same target rate, and the two answers are nowhere near each other. The variable that moved was not traffic. It was the pause between steps, which in one worked model runs 3 to 7 seconds between looking at a product and adding it to a cart. Sizing a flow-shaped test from a virtual user count someone quoted for a single-endpoint test gets this wrong in both directions: too few users to reach the rate, or so many that you are stress testing by accident.

One caveat on the formula: it assumes the API keeps up. As the service slows under load, response time grows, and a fixed pool of virtual users then delivers a lower rate than you sized for. That is useful information, and it is also why the number you feed the peak run is a starting position, not a setting you defend when the run tells you otherwise.

Model the flow as a weighted journey, not a single endpoint

A flow is an ordered sequence that one virtual user runs end to end: authenticate, create a resource, read it back, update it. The user does not start step three until step two has answered, and it carries forward whatever step two handed it.

That ordering is exactly what a single-endpoint run throws away. Google’s AI Overview for this question frames the move as going from concurrency, meaning how many callers hit one URL, to journeys, meaning how one customer moves through the application. The top-ranking answer, Grafana’s beginner’s guide to API load testing, gives the workload its own section, headed “Model the workload”, and stops at the modelling.

Real traffic is a mix, and the mix carries weights. The same model splits traffic into percentage-weighted personas, one of them a buyer who logs in, searches, adds to a cart, checks out and views an order confirmation, carrying 35 percent of traffic. Weighting matters because journeys cost different amounts. A checkout touches payment, inventory and mail; a search touches one index. A run that sends every virtual user down the expensive path produces a ceiling nobody will ever hit, and a run that sends none of them down it produces a ceiling that falls over in production.

There is a second thing flows catch that endpoints miss. Running one URL hard warms a cache that a real journey would never warm, and it never produces the interleaving of reads and writes that causes lock contention. A single-endpoint run against a read path can look fast for exactly the reason the flow behind it is slow.

The practical starting point is two or three journeys with weights that add to 100, each one a list of calls in order. Precision beyond that is worth less than getting the ordering and the weights roughly right. Pull the sequences from access logs if you have them, from the client code if you do not, and if they already live in a hand-maintained collection the ordering is written down there too. Check the weights against a day of real traffic before trusting a result.

Carry tokens, IDs and test data through the chain

A chained flow only works if every step hands the next one what it needs. The script reads a value out of one response and passes it into the following request: an auth token from the login call, a resource ID or a cart ID from the create call. Skip that and step three is calling a URL that does not exist yet, which is single-endpoint testing with extra steps.

Three things break this under load, and each one bends the result.

One shared login. Ten thousand virtual users authenticating as the same username will hit a database constraint block, or the system will serve back cached responses that do not match reality. The fix is a pool of test accounts the size of your peak virtual user count.

One shared data row. If every user updates the same record, you are load testing a row lock. A feeder file of unique credentials and product IDs, with each virtual user pulling its own row, keeps the run spread across distinct records.

One long-lived token. A token minted once and reused by every user skips the login step entirely, so the flow never measures the part of the system that issues credentials. Give each virtual user its own account and its own token comes with it.

Test data is the part teams underestimate. A 110 user peak needs 110 accounts and enough rows behind them for a run that loops for an hour, prepared before the run instead of generated during it. Generating fixtures inside the test adds the write cost of setup to every iteration and inflates your latency numbers with work no user does. Build the pool from fabricated accounts instead of copies of production records, which is a data-handling question before it is a testing one.

Plan the cleanup at the same time. An hour of a create-and-update journey at peak leaves a lot of leftover rows in the database, and the second run against that swollen table will not match the first. Either reset between runs or keep the data volume steady so two runs are comparable.

Find the throughput limit: the ramp and the signal

Before running anything, check whether the answer is already published. Many APIs return their limits in response headers: X-RateLimit-Limit carries the maximum number of requests allowed in a window, with X-RateLimit-Remaining, X-RateLimit-Reset and Retry-After alongside it and a 429 when you cross the line. If a configured limit is what you wanted to know, one request answers the question and no ramp is needed.

A physical ceiling is a different thing. With no limiter in front of the service, the limit comes from CPU, memory and database lock contention, which is why it has to be measured instead of looked up.

The ramp is a ladder, never a jump. A smoke run of 1 to 5 virtual users proves the script itself does not error. A baseline run and an average-load run follow. The peak run sits at whatever number the arithmetic produced. Only then does a breakpoint run push past it, climbing to 200 to 500 or more virtual users against a 110 user peak, going up slowly until something gives.

Each phase has a length worth planning. One profile gives a warm-up of 5 to 15 minutes so caches fill and autoscaling catches up, a steady hold of 1 to 4 hours to surface memory leaks and connection pool exhaustion, and a spike phase of 5 to 10 minutes aimed at autoscaling thresholds and graceful failure.

The signal you are ramping toward is precise. Keep adding virtual users until request rate flattens out while latency climbs steeply: past that point, extra users buy you no extra throughput. The last stable reading before 500, 502 and 504 responses appear is the number to write down. In a flow-shaped run, watch which step saturates first. It is usually the write, and that step is your real limit even when the read endpoints look healthy. Record the per-step rate at the last stable reading as well as the total: the whole-flow figure is what you report, and the per-step figure is what you take to whoever owns the slow service.

Read the run: percentiles decide, averages do not

An average response time over a flow run is close to useless. Most calls in a journey are cheap reads, and averaging them with the checkout hides the exact tail your users notice. A competing guide heads its results section percentiles, not averages.

Read p95 and p99, and read them per step instead of per run. A run where the whole-flow p95 looks acceptable can still contain one step that doubled, and the per-step view is the only place that shows up. The p99 matters more in a flow than in a single-endpoint test for an arithmetic reason: a journey of several calls gives every user several chances to land in the slow tail, so a tail that looks rare per request is common per journey.

A percentile on its own is a number, not a verdict. What turns it into a pass or a fail is a threshold set against an SLO before the run starts, the mechanism the top-ranking guide gives its own section. Write the thresholds down first, one ceiling for the checkout p95 and one for the error rate, at whatever numbers your service promises. Deciding afterwards what counts as acceptable is how a failing run gets argued into a passing one.

Error rate belongs in the same reading. A run that held its latency by returning 500s quickly is a failure that looks like a pass on a response time chart, so track the rate of non-2xx responses per step beside the percentiles.

Where flow-shaped load tests go wrong

The load generator is what you measured. Thousands of virtual users driven from one laptop max out local CPU and RAM, which skews the results and makes a healthy API look slow when the laptop is what is choking. If the generator’s own CPU is pinned, the run is void. Check the generator’s own metrics before reading the target’s, every time, and move to several machines or a hosted runner before the numbers get interesting.

Think time was left at zero by accident. A flow with no pauses is a legitimate test, it is the machine-to-machine profile that carries no think time, and it needs far fewer virtual users for the same rate. The mistake is running that profile while believing you modelled human traffic, which is the 3 to 7 second pause the model actually calls for.

The ceiling moved because the environment did. Without a limiter, the limit is hardware and database contention, so a staging box with a smaller database has a different ceiling than production. Compare runs against themselves over time, and treat a staging ceiling as a trend line instead of a promise. The useful question of a staging run is whether this week’s build is slower than last week’s, which a smaller environment answers well enough.

Where Stresseur fits

This section is about our own product. The procedure above stands without it.

Stresseur is an AI test engineer for APIs. It learns how an API is actually used, creates the tests every change needs, and keeps them current on every pull request. The part that matters here: any recorded flow can become a stress test, from one endpoint to ten thousand users. A recorded flow already carries the ordering and the chaining that the first half of this article asks you to rebuild by hand: the steps happened in that order, the token really was the one the login issued, and the weights are whatever your traffic actually did. The work left over is the sizing arithmetic and the ramp, which is the same work either way.

Stresseur is in early access, with a sign-up instead of self-serve pricing. We publish no benchmark figures, so nothing above is a Stresseur measurement: every number in this post belongs to the source cited beside it.

What to do on Monday

A flow-shaped load test is mostly bookkeeping done before the run. Write two or three journeys down with weights. Divide a peak hour by 3600 to get your target rate, multiply it by response time plus think time to get your head count, and prepare one account and one data row per virtual user. Then climb the ladder from a smoke run of 1 to 5 users upward, watching for the moment request rate stops climbing while latency does not. Agree the percentile thresholds before any of it, and the run comes back with an answer instead of an argument.

Frequently asked questions

Does a machine-to-machine API need think time in a load test?

No, and leaving it out is the point. Think time models a person pausing between steps, and a webhook or a service-to-service call has no pause. With think time at zero, a 50 RPS target against a 100 ms response needs five virtual users running in a continuous loop. Think time is the second input the sizing arithmetic asks for, after the target request rate.

Why does the same request rate need more virtual users when think time goes up?

Because a virtual user spends the pause doing nothing. The head count is target rate multiplied by response time plus think time. Two seconds of think time on a 200 ms response takes a 50 RPS target up to 110 virtual users, most of whom are idle at any given moment.

How many test accounts does a load test need?

Size the pool to your peak virtual user count. Ten thousand virtual users logging in under the same username will hit a database constraint block, or get a cached answer that does not match reality.

How long should each phase of the ramp run?

One worked profile gives a warm-up of 5 to 15 minutes, a steady hold of 1 to 4 hours to catch memory leaks and connection pool exhaustion, and a spike phase of 5 to 10 minutes against autoscaling thresholds. The hold is the long one because slow failures need time to show up.

Can I find an API's throughput limit without running a load test?

Sometimes. If the limit is configured instead of physical, the response headers carry it: X-RateLimit-Limit is the maximum number of requests allowed in a window. If nothing is limiting traffic, the ceiling comes from CPU, memory and database lock contention, and only a ramp finds it.

Can my laptop generate the load?

For a smoke run and a small baseline, yes. Thousands of virtual users from a single machine max out local CPU and RAM, which skews the numbers and makes a healthy API look slow. At that point the load generator is what you measured.

All posts