From 1b45e2439289fd8e094c44161c89e06c5488671e Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Thu, 2 Apr 2026 19:00:28 +0200 Subject: [PATCH] Add Flight SSR benchmark fixture (#36180) This PR adds a benchmark fixture for measuring the performance overhead of the React Server Components (RSC) Flight rendering compared to plain Fizz server-side rendering. ### Motivation Performance discussions around RSC (e.g. #36143, #35125) have highlighted the need for reproducible benchmarks that accurately measure the cost that Flight adds on top of Fizz. This fixture provides multiple benchmark modes that can be used to track performance improvements across commits, compare Node vs Edge (web streams) overhead, and identify bottlenecks in Flight serialization and deserialization. ### What it measures The benchmark renders a dashboard app with ~25 components (16 client components), 200 product rows with nested data (~325KB Flight payload), and ~250 Suspense boundaries in the async variant. It compares 8 render variants: Fizz-only and Flight+Fizz, across Node and Edge stream APIs, with both synchronous and asynchronous apps. ### Benchmark modes - **`yarn bench`** runs a sequential in-process benchmark with realistic Flight script injection (tee + `TransformStream`/`Transform` buffered injection), matching what real frameworks do when inlining the RSC payload into the HTML response for hydration. - **`yarn bench:bare`** runs the same benchmark without script injection, isolating the React-internal rendering cost. This is best for tracking changes to Flight serialization or Fizz rendering. - **`yarn bench:server`** starts an HTTP server and uses `autocannon` to measure real req/s at `c=1` and `c=10`. The `c=1` results provide a clean signal for tracking React-internal changes, while `c=10` reflects throughput under concurrent load. - **`yarn bench:concurrent`** runs an in-process concurrent benchmark with 50 in-flight renders via `Promise.all`, measuring throughput without HTTP overhead. - **`yarn bench:profile`** collects CPU profiles via the V8 inspector and reports the top functions by self-time along with GC pause data. - **`yarn start`** starts the HTTP server for manual browser testing. Appending `.rsc` to any Flight URL serves the raw Flight payload. ### Key findings during development On Node 22, the Flight+Fizz overhead compared to Fizz-only rendering is roughly: - **Without script injection** (`bench:bare`): ~2.2x for sync, ~1.3x for async - **With script injection** (`bench:server`, c=1): ~2.9x for sync, ~1.8x for async - **Edge vs Node** adds another ~30% for sync and ~10% for async, driven by the stream plumbing for script injection (tee + `TransformStream` buffering) The async variant better represents real-world applications where server components fetch data asynchronously. Its lower overhead reflects the fact that Flight serialization and Fizz rendering can overlap with I/O wait times, making the added Flight cost a smaller fraction of total request time. The benchmark also revealed that the Edge vs Node gap is negligible for Fizz-only rendering (~1-2%) but grows to ~15% for Flight+Fizz sync even without script injection. With script injection (tee + `TransformStream` buffering), the gap roughly doubles to ~30% for sync. The async variants show smaller gaps (~5% without, ~10% with injection). --- fixtures/flight-ssr-bench/README.md | 62 + fixtures/flight-ssr-bench/bench-server.js | 350 ++++ fixtures/flight-ssr-bench/bench.js | 726 ++++++++ fixtures/flight-ssr-bench/package.json | 34 + fixtures/flight-ssr-bench/print-helpers.js | 54 + fixtures/flight-ssr-bench/render-helpers.js | 329 ++++ .../flight-ssr-bench/rsc-client-ref-loader.js | 22 + fixtures/flight-ssr-bench/src/App.js | 18 + fixtures/flight-ssr-bench/src/AppAsync.js | 18 + .../src/components/ActivityFeed.js | 21 + .../src/components/ActivityItem.js | 27 + .../flight-ssr-bench/src/components/Avatar.js | 13 + .../flight-ssr-bench/src/components/Badge.js | 6 + .../src/components/ChartPanel.js | 24 + .../src/components/Dashboard.js | 32 + .../src/components/DashboardAsync.js | 144 ++ .../flight-ssr-bench/src/components/Footer.js | 47 + .../src/components/FooterLink.js | 9 + .../flight-ssr-bench/src/components/Header.js | 22 + .../src/components/NavLink.js | 10 + .../src/components/NotificationBell.js | 10 + .../src/components/Pagination.js | 26 + .../src/components/ProductTable.js | 40 + .../src/components/SearchBar.js | 11 + .../flight-ssr-bench/src/components/Shell.js | 16 + .../src/components/Sidebar.js | 58 + .../src/components/SidebarSection.js | 10 + .../src/components/Skeleton.js | 9 + .../src/components/StatCard.js | 24 + .../src/components/StatsGrid.js | 18 + .../src/components/TableHeader.js | 10 + .../src/components/TableRow.js | 31 + .../src/components/ThemeProvider.js | 9 + .../flight-ssr-bench/src/components/data.js | 293 ++++ fixtures/flight-ssr-bench/src/entry-rsc.js | 22 + fixtures/flight-ssr-bench/webpack-mock.js | 62 + fixtures/flight-ssr-bench/webpack.config.js | 45 + fixtures/flight-ssr-bench/yarn.lock | 1463 +++++++++++++++++ package.json | 1 + 39 files changed, 4126 insertions(+) create mode 100644 fixtures/flight-ssr-bench/README.md create mode 100644 fixtures/flight-ssr-bench/bench-server.js create mode 100644 fixtures/flight-ssr-bench/bench.js create mode 100644 fixtures/flight-ssr-bench/package.json create mode 100644 fixtures/flight-ssr-bench/print-helpers.js create mode 100644 fixtures/flight-ssr-bench/render-helpers.js create mode 100644 fixtures/flight-ssr-bench/rsc-client-ref-loader.js create mode 100644 fixtures/flight-ssr-bench/src/App.js create mode 100644 fixtures/flight-ssr-bench/src/AppAsync.js create mode 100644 fixtures/flight-ssr-bench/src/components/ActivityFeed.js create mode 100644 fixtures/flight-ssr-bench/src/components/ActivityItem.js create mode 100644 fixtures/flight-ssr-bench/src/components/Avatar.js create mode 100644 fixtures/flight-ssr-bench/src/components/Badge.js create mode 100644 fixtures/flight-ssr-bench/src/components/ChartPanel.js create mode 100644 fixtures/flight-ssr-bench/src/components/Dashboard.js create mode 100644 fixtures/flight-ssr-bench/src/components/DashboardAsync.js create mode 100644 fixtures/flight-ssr-bench/src/components/Footer.js create mode 100644 fixtures/flight-ssr-bench/src/components/FooterLink.js create mode 100644 fixtures/flight-ssr-bench/src/components/Header.js create mode 100644 fixtures/flight-ssr-bench/src/components/NavLink.js create mode 100644 fixtures/flight-ssr-bench/src/components/NotificationBell.js create mode 100644 fixtures/flight-ssr-bench/src/components/Pagination.js create mode 100644 fixtures/flight-ssr-bench/src/components/ProductTable.js create mode 100644 fixtures/flight-ssr-bench/src/components/SearchBar.js create mode 100644 fixtures/flight-ssr-bench/src/components/Shell.js create mode 100644 fixtures/flight-ssr-bench/src/components/Sidebar.js create mode 100644 fixtures/flight-ssr-bench/src/components/SidebarSection.js create mode 100644 fixtures/flight-ssr-bench/src/components/Skeleton.js create mode 100644 fixtures/flight-ssr-bench/src/components/StatCard.js create mode 100644 fixtures/flight-ssr-bench/src/components/StatsGrid.js create mode 100644 fixtures/flight-ssr-bench/src/components/TableHeader.js create mode 100644 fixtures/flight-ssr-bench/src/components/TableRow.js create mode 100644 fixtures/flight-ssr-bench/src/components/ThemeProvider.js create mode 100644 fixtures/flight-ssr-bench/src/components/data.js create mode 100644 fixtures/flight-ssr-bench/src/entry-rsc.js create mode 100644 fixtures/flight-ssr-bench/webpack-mock.js create mode 100644 fixtures/flight-ssr-bench/webpack.config.js create mode 100644 fixtures/flight-ssr-bench/yarn.lock diff --git a/fixtures/flight-ssr-bench/README.md b/fixtures/flight-ssr-bench/README.md new file mode 100644 index 000000000000..98cefe6ba7de --- /dev/null +++ b/fixtures/flight-ssr-bench/README.md @@ -0,0 +1,62 @@ +# Flight SSR Benchmark + +Measures the performance overhead of the React Server Components (RSC) Flight pipeline compared to plain Fizz server-side rendering, across both Node and Edge (web streams) APIs. + +## Prerequisites + +Build React from the repo root first: + +```sh +yarn build-for-flight-prod +``` + +Then install the fixture's dependencies: + +```sh +cd fixtures/flight-ssr-bench +yarn install +``` + +## Scripts + +| Script | Purpose | +| --- | --- | +| `yarn bench` | Sequential benchmark with Flight script injection (realistic framework pipeline). Best for measuring Edge vs Node overhead. | +| `yarn bench:bare` | Sequential benchmark without script injection. Best for measuring React-internal changes (e.g. Flight serialization optimizations) with less noise from stream plumbing. | +| `yarn bench:server` | HTTP server benchmark using autocannon at c=1 and c=10. Best for measuring real-world req/s. The c=1 results are also useful for tracking React-internal changes. | +| `yarn bench:concurrent` | In-process concurrent benchmark (50 in-flight renders). Measures throughput under load without HTTP overhead. | +| `yarn bench:profile` | CPU profiling via V8 inspector. Saves `.cpuprofile` files to `build/profiles/`. | +| `yarn start` | Starts the HTTP server for manual browser testing at `http://localhost:3001`. Append `.rsc` to any Flight URL to see the raw Flight payload. | + +## What it measures + +Each script benchmarks 8 render variants: + +- **Fizz (Node, sync/async)** -- plain `renderToPipeableStream`, no RSC +- **Fizz (Edge, sync/async)** -- plain `renderToReadableStream`, no RSC +- **Flight + Fizz (Node, sync/async)** -- full RSC pipeline: Flight server (`renderToPipeableStream`) -> Flight client (`createFromNodeStream`) -> Fizz (`renderToPipeableStream`) +- **Flight + Fizz (Edge, sync/async)** -- full RSC pipeline: Flight server (`renderToReadableStream`) -> Flight client (`createFromReadableStream`) -> Fizz (`renderToReadableStream`) + +The "sync" variants use a fully synchronous app (no Suspense boundaries). The "async" variants use per-row async components with staggered delays and individual Suspense boundaries (~250 boundaries per render). + +### Script injection + +The `yarn bench` and `yarn bench:server` scripts simulate what real frameworks do: tee the Flight stream and inject `'; + }); + + rscPipe(trunk); + flightStream = forSsr; + } else { + flightStream = new PassThrough(); + rscPipe(flightStream); + } + + let cachedResult; + function Root() { + if (!cachedResult) { + cachedResult = createFromNodeStream(flightStream, ssrManifest); + } + return React.use(cachedResult); + } + + const output = new PassThrough(); + + const {pipe} = renderToPipeableStream(React.createElement(Root), { + onShellReady() { + if (inject) { + // Buffer HTML chunks within a tick to avoid injecting scripts mid-tag. + const trailer = ''; + let buffered = []; + let timeout = null; + const injector = new Transform({ + transform(chunk, _encoding, cb) { + buffered.push(chunk); + if (!timeout) { + timeout = setTimeout(() => { + for (const buf of buffered) { + let str = buf.toString(); + if (str.endsWith(trailer)) { + str = str.slice(0, -trailer.length); + } + this.push(str); + } + buffered.length = 0; + timeout = null; + if (flightScripts) { + this.push(flightScripts); + flightScripts = ''; + } + }, 0); + } + cb(); + }, + flush(cb) { + if (timeout) { + clearTimeout(timeout); + for (const buf of buffered) { + let str = buf.toString(); + if (str.endsWith(trailer)) { + str = str.slice(0, -trailer.length); + } + this.push(str); + } + buffered.length = 0; + } + if (flightScripts) { + this.push(flightScripts); + flightScripts = ''; + } + this.push(trailer); + cb(); + }, + }); + pipe(injector); + injector.pipe(output); + } else { + pipe(output); + } + }, + onError(e) { + console.error('Flight+Fizz Node error:', e); + output.destroy(e); + }, + }); + + return output; +} + +// --------------------------------------------------------------------------- +// Flight + Fizz (Edge) — RSC render → tee → Fizz + script injection via web +// streams. HTML chunks are buffered within a tick to avoid injecting scripts +// mid-tag. The trailer is stripped, Flight scripts injected, +// and the trailer re-added at flush. +// Returns a promise that resolves to a web ReadableStream. +// --------------------------------------------------------------------------- + +function renderFlightFizzEdge( + renderRSCEdge, + AppComponent, + itemCount, + clientManifest, + ssrManifest, + opts +) { + const inject = !opts || opts.inject !== false; + const React = require('react'); + const {renderToReadableStream} = require('react-dom/server'); + const { + createFromReadableStream, + } = require('react-server-dom-webpack/client.edge'); + + const webStream = renderRSCEdge(clientManifest, AppComponent, itemCount); + + let forSsr; + let injector; + + if (inject) { + const htmlTrailer = ''; + const enc = new TextEncoder(); + + let forInline; + [forSsr, forInline] = webStream.tee(); + + let resolveInline; + const inlinePromise = new Promise(function (r) { + resolveInline = r; + }); + const htmlDecoder = new TextDecoder(); + let buffered = []; + let timeout = null; + + function flushBuffered(controller) { + for (const chunk of buffered) { + let buf = htmlDecoder.decode(chunk, {stream: true}); + if (buf.endsWith(htmlTrailer)) { + buf = buf.slice(0, -htmlTrailer.length); + } + controller.enqueue(enc.encode(buf)); + } + const remaining = htmlDecoder.decode(); + if (remaining.length) { + let buf = remaining; + if (buf.endsWith(htmlTrailer)) { + buf = buf.slice(0, -htmlTrailer.length); + } + controller.enqueue(enc.encode(buf)); + } + buffered.length = 0; + timeout = null; + } + + function writeFlightChunk(data, controller) { + controller.enqueue( + enc.encode( + '' + ) + ); + } + + injector = new TransformStream({ + start(controller) { + (async function () { + const reader = forInline.getReader(); + const decoder = new TextDecoder('utf-8', {fatal: true}); + for (;;) { + const {done, value} = await reader.read(); + if (done) break; + writeFlightChunk(decoder.decode(value, {stream: true}), controller); + } + const remaining = decoder.decode(); + if (remaining.length) { + writeFlightChunk(remaining, controller); + } + resolveInline(); + })(); + }, + transform(chunk, controller) { + buffered.push(chunk); + if (!timeout) { + timeout = setTimeout(function () { + flushBuffered(controller); + }, 0); + } + }, + async flush(controller) { + await inlinePromise; + if (timeout) { + clearTimeout(timeout); + flushBuffered(controller); + } + controller.enqueue(enc.encode(htmlTrailer)); + }, + }); + } else { + forSsr = webStream; + } + + const cachedResult = createFromReadableStream(forSsr, { + serverConsumerManifest: ssrManifest, + }); + function Root() { + return React.use(cachedResult); + } + + return renderToReadableStream(React.createElement(Root)).then( + function (htmlStream) { + return injector ? htmlStream.pipeThrough(injector) : htmlStream; + } + ); +} + +// --------------------------------------------------------------------------- +// Utilities: collect streams into strings. +// --------------------------------------------------------------------------- + +function nodeStreamToString(nodeStream) { + return new Promise(function (resolve, reject) { + const chunks = []; + nodeStream.on('data', function (chunk) { + chunks.push(chunk); + }); + nodeStream.on('end', function () { + resolve(Buffer.concat(chunks).toString('utf-8')); + }); + nodeStream.on('error', reject); + }); +} + +function webStreamToString(webStream) { + const reader = webStream.getReader(); + const chunks = []; + function read() { + return reader.read().then(function ({done, value}) { + if (done) { + return Buffer.concat(chunks).toString('utf-8'); + } + chunks.push(Buffer.from(value)); + return read(); + }); + } + return read(); +} + +module.exports = { + renderFizzNode, + renderFizzEdge, + renderFlightFizzNode, + renderFlightFizzEdge, + nodeStreamToString, + webStreamToString, +}; diff --git a/fixtures/flight-ssr-bench/rsc-client-ref-loader.js b/fixtures/flight-ssr-bench/rsc-client-ref-loader.js new file mode 100644 index 000000000000..325d5b342dab --- /dev/null +++ b/fixtures/flight-ssr-bench/rsc-client-ref-loader.js @@ -0,0 +1,22 @@ +'use strict'; + +const url = require('url'); + +// Webpack loader that runs in the RSC compilation. +// When a module starts with 'use client', it replaces the entire source +// with a client module proxy. This makes the RSC renderer serialize a +// client reference into the Flight stream instead of rendering the component. +module.exports = function rscClientRefLoader(source) { + const trimmed = source.trimStart(); + if ( + trimmed.startsWith("'use client'") || + trimmed.startsWith('"use client"') + ) { + const href = url.pathToFileURL(this.resourcePath).href; + return [ + `const { createClientModuleProxy } = require('react-server-dom-webpack/server');`, + `module.exports = createClientModuleProxy(${JSON.stringify(href)});`, + ].join('\n'); + } + return source; +}; diff --git a/fixtures/flight-ssr-bench/src/App.js b/fixtures/flight-ssr-bench/src/App.js new file mode 100644 index 000000000000..c50ea1bd3f5f --- /dev/null +++ b/fixtures/flight-ssr-bench/src/App.js @@ -0,0 +1,18 @@ +import Shell from './components/Shell'; +import Sidebar from './components/Sidebar'; +import Dashboard from './components/Dashboard'; +import Footer from './components/Footer'; + +export default function App({itemCount}) { + return ( + + + + + +