Load Testing an SSR Migration — Making the Numbers Comparable
Hero image credit: Photo by Xiao Bo on Pexels
Part 19 of the Nuxt and .NET series.
At some point somebody asks whether the new stack is faster than the one it replaced. It is a fair question, and answering it honestly is harder than running a load test — because the two systems were measured by different tools, at different boundaries, over different populations of requests.
This article is about the method and about what a result of this kind can and cannot support. Traffic distributions, route names, exact measurements and resource allocations are deliberately absent; observations appear as broad ranges rather than as a fingerprint of a particular deployment.
Table of Contents
- A Fast Response on a Laptop Proves Nothing
- Define the Statistic Before Computing an Improvement
- Why the Headline Ratio Is Not Available
- Modelling Traffic Without Publishing It
- Sustained and Stepped Runs
- What the Runs Support
- Tails Failures and Thresholds
- Sizing Experiments in the Same Harness
- Browser Performance Is a Different Experiment
- Cost Is a Different Experiment
- Reporting Rules
- What’s Next
A Fast Response on a Laptop Proves Nothing
A developer’s document page renders quickly, on a warm cache, with one visitor, no competing renders, a local dependency, and no proxy in front. Every one of those conditions is doing work that production will not do for you.
Validating a migration therefore needs three things that are usually assembled in the wrong order: a workload that resembles reality, acceptance criteria written before the run, and statistics defined precisely enough that a comparison means something. The third is where most published performance claims fall apart, so it goes first.
Define the Statistic Before Computing an Improvement
| Metric | What it means | The usual mistake |
|---|---|---|
| Median request duration | The middle observation of the request-duration distribution | Calling an average of per-page medians “the median” |
| P95 | The boundary below which about 95% of observations fall | Treating it as an upper bound on the slowest 5% |
| Error rate | Failures divided by requests in the same population | Comparing one route’s historical rate with an all-route result |
| Delivered throughput | Work actually completed per unit time | Reporting the requested arrival rate as achieved capacity |
| Maximum tested load | The highest load this test exercised | Calling it the system’s capacity |
Two of those rows are worth expanding, because they are the ones that produce confident wrong statements.
A traffic-weighted average of per-page medians is a genuinely useful summary:
sum(page median × page traffic weight) / sum(page traffic weight)
It is not the median of the combined request population, and it cannot be relabelled as one. A distribution generally cannot be reconstructed from its group medians, so this number should always travel with its definition attached.
And an error rate is a property of a population. A historical figure computed over one route class and a test result computed over all routes are two different quantities; dividing one by the other produces a ratio with no meaning, however impressive.
Why the Headline Ratio Is Not Available
This is the part a stakeholder will not enjoy, and it is the honest position.
Historical production aggregates and a fresh load-test run measure different things. Server-side processing duration, client-observed HTTP duration, and the total time of a multi-request navigation are three boundaries, and the old system’s records and the new system’s test typically do not share one. Nor do they share an aggregation method, a traffic mix, a cache state or a measurement window.
So this series does not claim a whole-system speedup ratio or an error-rate reduction factor from those mixed summaries. What is available is described below: broad ranges for the scale of the observations, and behaviour under tested load. A defensible qualitative statement beats an indefensible number, and anybody who has been asked to justify such a number in a review knows which one survives the second meeting.
If a like-for-like ratio genuinely matters, it has to be produced deliberately: the same statistic, at the same boundary, over the same route population, on both systems, ideally by running the same harness against both.
Modelling Traffic Without Publishing It
The load scripts select from a bounded set of routes using weights derived from historical traffic. That is synthetic HTTP traffic informed by production observations, which is a different thing from replaying real browser sessions.
Keep the actual route list and weights private — they describe a business, not a technique. For internal reproducibility, store with the test artefact everything needed to repeat it: test revision, target release, runtime versions, cache state, region, load-generator placement, authentication mode and workload parameters. A result whose conditions were not recorded cannot be compared with its own successor.
One scope limitation to state up front: a GET-only test does not exercise writes. State-changing operations need separate, authorized scenarios, and they must not run destructively against real data.
Sustained and Stepped Runs
Two run shapes answer two different questions.
A sustained run ramps to a chosen arrival rate and holds it, which is how you learn whether the system is stable at a level resembling normal traffic. The warm-up policy belongs in the report, because it determines cache state — and given how much of this architecture depends on the caches from Parts 3 and 9, a run against a fully warm cache and a run against a cold one are measuring two different systems. Both are legitimate; conflating them is not.
A stepped run increases offered load in stages with a hold at each level:
The holds are the valuable part: they show whether the system settles after a step — new replicas started, caches refilled, queues drained — or merely survived the transient. A ramp with no holds tells you very little.
Two things a stepped run does not establish. Stopping at a chosen multiple of baseline because the criteria still passed does not locate a ceiling; it locates where you stopped. And a fifteen-minute step is not a soak: retention effects and cache-eviction behaviour need a longer sustained run, which is the same point Part 17 made about memory conclusions drawn from short windows.
What the Runs Support
Stated at the level this series can publish: the observations span seconds-scale response summaries for the legacy stack and hundreds of milliseconds for many requests in the revised stack, and the revised application was exercised at several multiples of the selected baseline while meeting the criteria in force.
Both of those are deliberately modest. The first describes the scale of the observations without asserting a like-for-like ratio, for the reasons above. The second describes tested behaviour for one workload on one configuration — not a capacity multiplier over the previous system, which was never run through the same harness.
The most transferable finding is methodological: a stable median can coexist with a degrading tail. Most requests can remain fast while a minority wait through a cold cache, a slow dependency, a retry chain or a worker startup — and users in that minority are the ones who write support tickets. A result reported only as a median can hide precisely the population that motivated the work.
Tails Failures and Thresholds
So report the set, not the headline: median, tail percentiles, failure rate, delivered throughput, and dropped iterations — the ones the generator could not issue, which are how you distinguish “the system handled the load” from “the load generator gave up.”
Look at route-level distributions as well as the aggregate. A consistently slow page with a small traffic weight disappears into a weighted summary and is still a bad page.
Two warnings about apparent passes. Exploratory scripts usually carry permissive thresholds, so a zero exit code means “the thresholds configured for exploration were met,” which is not a production-readiness statement; release criteria should come from user needs and error budgets and live separately from stress-test limits. And an HTTP success status does not mean a useful page: a sign-in redirect, an error page or a partial render can all return 200-range responses, which is the same weakness Part 16 identified in a smoke check. If content correctness is part of the test’s purpose, assert content.
Sizing Experiments in the Same Harness
The load harness is also what makes Part 17’s sizing procedure possible, because changing a memory or worker setting is only meaningful under a repeatable workload:
The finding such experiments support is a trade-off rather than a number: reducing memory and CPU improves utilization until garbage-collection pressure, request queuing or slower startup undermines reliability. There is no universal minimum container size for an SSR application, and anyone quoting one is describing their own component tree.
Keep the three memory quantities distinct while doing this — container working set, process resident set, and V8 heap — because a percentage of container memory is not heap utilization, and attributing a failure to memory pressure requires correlating GC and probe events rather than pattern-matching a graph.
Browser Performance Is a Different Experiment
HTTP load scripts do not execute page JavaScript, hydrate components, or fetch subresources the way a browser does. They therefore cannot measure anything from Parts 13 and 14: deferred hydration, font behaviour, image delivery, interaction readiness. A load test that got faster says nothing about whether the revision switcher responds sooner.
Those questions need browser measurements, with a declared device profile, throttling method and cache state, repeated rather than run once. And a lab audit remains a lab observation: field responsiveness and accessibility are separate assessments, not corollaries of a score.
The parity rule from Part 13 applies here too. If functionality is disabled for audit user agents, the resulting number describes a configuration no visitor experiences, and should not be published as if it did.
Cost Is a Different Experiment
Smaller application replicas do not produce a proportional bill reduction. Dedicated node capacity has its own minimum, retained revisions for rollback occupy resources, and managed caches, monitoring, networking and storage are line items that do not scale with replica count.
If cost is the question, compare actual spend over equivalent periods and workloads. For publication, the useful content is the trade-off — baseline capacity against scaling headroom against operational complexity — not a figure.
Reporting Rules
Before publishing any measurement from work like this:
- State the metric definition and confirm the populations being compared are the same.
- Keep exact evidence in private records, not in public screenshots or attachments.
- Use broad ranges or orders of magnitude consistently — in the text, the tables and the diagrams.
- Remove exact workload distributions, allocation combinations and identifiable route names.
- Label synthetic examples as examples rather than observations.
- Say what was not measured, and avoid causal claims without a controlled comparison.
The rule underneath all six: a credible performance result is not the most dramatic ratio available, it is the statement whose scope and limitations a reader can check. “The revised stack served several multiples of baseline traffic with stable tail latency under these conditions, and we did not run a like-for-like comparison against the old system” is a sentence that survives scrutiny. A single impressive multiplier usually is not.
What’s Next
Part 20: The Full Picture returns to Part 1’s one-field change and asks whether the machinery built over these twenty articles was worth owning.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Advanced Web App With Nuxt And Net