How to benchmark React SSR performance

A useful SSR benchmark answers a specific question: how much correct work can the application complete within a resource budget, at what latency and cost? A requests-per-second number alone leaves too much unexplained.

This method applies to versions, profiles or React runtimes. It separates HTTP measurements from browser journeys and includes a k6 example to adapt to a fixture route. The example itself establishes no performance result.

Define a correct response

Choose a representative route: a dynamic list, an API or a streaming page. Fix the data volume, dependencies and authentication conditions. A fixture route can return a unique request marker to detect stale or incorrectly shared responses.

Validate status, content type and an expected body value. For dynamic SSR, verify that rendering actually occurs. A cached static file and a freshly rendered page are different workloads.

Make environments comparable

Identify the commit, artifacts, Node, React, operating system and compiler options. On a given machine, retain the same Node executable and application dependencies. Use production builds and record cache and compression settings.

Set budgets for the complete group, including workers and any proxy. Explain topology: two processes behind a proxy need context when compared with a direct server. Prefer a separate load-generator machine and monitor whether it saturates.

Create a dynamic fixture route

In an App Router test application, create app/bench/page.jsx with this content. The marker varies by request and dynamic rendering is explicit. This example does very little work; replace it with representative rendering when evaluating a business scenario.

Keep the same source for the runtimes being compared. A marker alone does not prove isolation of all data: add separate sessions and application-specific assertions when users are part of the scenario.

app/bench/page.jsx
export const dynamic = "force-dynamic";

export default async function Benchmark({ searchParams }) {
  const { id = "missing" } = await searchParams;
  return <main>Benchmark marker: {id}</main>;
}

Add validation to the load generator

Save this example as bench.js. It expects a /bench fixture route that echoes the id query parameter into its HTML. Create that route in your test application; neither Next.js nor PRNext supplies it automatically. BASE_URL targets a test instance you control.

The checks threshold invalidates the run when a response fails validation. Redirects are disabled to avoid silently measuring a different page. This is a closed-concurrency example: when the server slows down, each virtual user waits longer. It does not simulate a fixed arrival rate.

bench.js
import http from "k6/http";
import { check } from "k6";

export const options = {
  vus: 16,
  duration: "60s",
  thresholds: { checks: ["rate==1"], http_req_failed: ["rate==0"] }
};

export default function () {
  const id = `request-${__VU}-${__ITER}`;
  const base = __ENV.BASE_URL || "http://127.0.0.1:3000";
  const response = http.get(`${base}/bench?id=${id}`, { redirects: 0 });
  check(response, {
    "status 200": r => r.status === 200,
    "HTML response": r => String(r.headers["Content-Type"]).includes("text/html"),
    "fresh marker": r => typeof r.body === "string" && r.body.includes(id)
  });
}

Separate warm-up from measured windows

With k6 installed, run a separate warm-up and then multiple measured windows. Alternate candidate order and keep each run’s output. The command uses POSIX shell environment syntax; adapt BASE_URL assignment in PowerShell.

The generator has its own network and CPU cost. Keep parameters identical for both candidates and record competing server activity. Repeated windows help describe variability, but do not remove topology bias or make an unrepresentative fixture representative.

Terminal
BASE_URL=http://127.0.0.1:3000 k6 run --duration 10s bench.js
BASE_URL=http://127.0.0.1:3000 k6 run --summary-export result-a.json bench.js

Read throughput, latency, CPU and memory together

The p95 is a duration below which 95% of sampled requests complete. In k6, http_req_duration excludes phases such as DNS lookup and initial connection establishment. Name the metric rather than calling it speed.

Compute group CPU work per correct response separately over the measured window: the group’s CPU delta divided by validated responses. HTTP p95 is not CPU consumption. Record group memory and process-tree PSS with the method stated. Invalidate a window containing incorrect content before comparing performance.

Add a browser journey

A client can receive HTML quickly and still wait for hydration. Measure navigation, back navigation and a business interaction separately. Check that counters, sessions or form state remain correct throughout the journey.

In our VPS comparison, server gains do not translate into wins across all browser measurements. Publishing those differences makes the evaluation more useful. Keep the protocol, raw output and limitations alongside charts.

Read our SSR and navigation campaign on two VPS machines

Sources and further reading

Back to the blog