HTML-first React · compared with stock React main · measured at 31b26561f1 · 10 November 2026
HTML-first React: results
On first load, an RSC page sends its static markup twice: as HTML, so the browser can paint, and again inside the Flight payload the browser inlines to hydrate. For content that never changes on the client, the second copy is wasted bandwidth, and it costs the server time to produce.
This experiment adds a flag that leaves that content out of the second copy. Across 41 test pages it cuts the gzipped download by 43%. On a Waku page with a 1000-row table rendered by Server Components it raises throughput 3.2×. A framework turns it on with one server option and, for full safety, one client callback. The code is PR #24, a reference implementation (final version df356153a6).
How it works
With enableFlightStaticHostElision on, Flight does not write static host markup into the payload the browser receives. It writes a small stub instead, and the browser adopts the DOM that the HTML already contains. SSR still needs the real content to write that HTML, so Flight hands it over through an internal registry.
Markup qualifies when it is plain output of a Server Component in a place React renders in place:
- children of host elements and of Suspense boundaries,
childrenof Client Components (layouts, slots),- the entries of a root element map such as
{page, layout}.
Client Components are never elided. Neither are props other than children, because the client can render those later with no server DOM behind them, nor forms, inputs, images, SVG or head elements.
The feature is opt-in per request. The framework passes elideStaticHosts: true on the Flight render that feeds HTML SSR, and leaves it off everywhere else, so navigation, refetch and action responses stay ordinary full payloads. React cannot tell where a stream is going, because the bytes are identical either way. Frameworks can: Waku serves RSC requests under an RSC/ URL prefix, and Next.js sends an RSC: 1 header.
Bytes across 41 test pages
Each page is rendered at full size with the flag off and on. Gzip and brotli are of the whole document, and Flight is the payload inlined in <script> tags. The table sums all 41 pages.
| Metric | Flag off | Flag on | Change |
|---|---|---|---|
| Gzip download | 1,023,906 | 586,852 | −42.7% |
| Raw HTML | 14,555,714 | 7,252,862 | −50.2% |
| Inlined Flight | 9,280,754 | 1,977,902 | −78.7% |
| Brotli | 479,414 | 286,520 | −40.2% |
The total is dominated by a few pages: the five largest savings make up about 80% of the gzip bytes saved. The median page saves 7.8%, nine pages are within ±1%, and seven get slightly larger, the worst being repeated-long-strings at +12.4%. A page with nothing to elide, like all-client, pays about 0.6% for the registry row. Per-page numbers are on the examples page.
| Page | gzip | inlined Flight |
|---|---|---|
| wide-tree | −79% | −99.9% |
| client-composition-slot | −59% | −94% |
| docs-sidebar-toc | −59% | −98% |
| server-table-client-rows | −50% | −95% |
| nested-layouts-context | −41% | −89% |
| all-client | +0.6% | +15% |
Throughput on Waku
The test page is a 1000-row table rendered by Server Components, plus inline CSS and JSON-LD. Waku was chosen because it is easy to patch and has good RSC support; the feature does not depend on it. The patch has two parts: the HTML render passes elideStaticHosts: true, and the root refetches the initial payload when React reports that elided content is needed. Figures are medians over 5 rounds on one laptop, with the order of the servers rotated each round.
| Stock | Flag on, stock Waku | Flag on, patched Waku | |
|---|---|---|---|
| Requests/s | 1.00 | 1.00 | 3.18× (3.13–3.22) |
| TTFB | 1.00 | 0.97 | 0.45× |
| Inlined Flight | 823,655 B | 823,655 B | 18,978 B |
| Gzip HTML | 130,459 B | 130,460 B | 60,889 B |
With the flag on and no opt-in, Waku's output is byte-identical to stock: forgetting the option loses the savings but breaks nothing. A second page whose rows are Client Components improves only 1.10×, since those rows cannot be elided. Next.js was not tried.
Correctness
The 41 pages are loaded in Chromium with and without the flag. For each one the check compares the hydrated DOM, the DOM after interaction, and the DOM after a client navigation to a full payload, and it fails on any console or hydration error. All 41 pass at 31b26561f1. The comparison normalizes style spacing, zero-length CSS units and attribute order. Firefox and WebKit passed on earlier commits but have not been run on this one. The Waku page hydrates, its counters respond, it logs no errors, and a client navigation renders all 1000 rows.
The same kind of check also runs in CI on Linux, with the flag off and on, in real Chromium. The latest CI report covers 10 page variants at df356153a6: every one adopts 1000 of 1000 rows from the server HTML, logs no console problems and no recoverable hydration errors, and navigates correctly.
On a client navigation the full payload contains real elements where the stubs were. React builds fresh DOM for those regions rather than reusing the old DOM, whose content may not match the new page. Client components keep their state.
Limits
- If an elided region is rendered on the client with no server DOM behind it (a modal or client-only wrapper that mounts after hydration, or a boundary that client-renders after an error), React cannot recreate the content. The framework must pass
onElidedContentMissingand refetch a full payload. The Waku patch does, and a modal that mounts its static children after a click then shows them as it does on stock. Without it the page reports an uncaught error that names the element. - Preload links hoisted into
<head>can differ from stock. The resources that matter are the same. - The savings scale with the amount of static server markup on a page. Expect many real pages to look like the median, not like the largest cases.
Try it yourself
You need Node 22 and npm. Nothing is cloned or built: the starter app installs a React build with the flag on and a Waku with the patch, both from a GitHub release.
npx degit MaxwellCohen/react-eco-ci/starters/waku-static-host-elision elision-demo
cd elision-demo
npm install
npm run build
npm start # http://localhost:8080
npm run measure # in a second terminal
npm run measure prints the size of the two table pages next to what stock React and stock Waku send for the same pages. Then open /table-host and view the source: the inlined Flight payload is small and contains $J stubs. Open /late-modal and click “open”: that content was not in the first payload, and appears because Waku refetches it. I have run npm run build and npm start, not npm run dev.
To add the packages to your own Waku app, install the four tarballs from the release and override scheduler and react-is as well; the exact command and overrides are in the react-eco-ci README. The app needs to work on Waku 1.0.0-rc.4, and these are experimental builds, not for production.
- React part: branch
rsc/html-first-one-flagatdf356153a6(PR #24), built withenableFlightStaticHostElisionforced on. - Waku part: the patch against upstream Waku (branch
rsc-html-first-optin): two small changes, inrender.tsandclient-runtime.tsx. - Packages and starter: release with the tarballs, the starter app.
- Build them yourself: the README has the CI inputs for the
react-buildworkflow and the local steps.
Links
- PR #24, the reference implementation, based on
facebook/reactmain (final versiondf356153a6) - Examples: all 41 test pages, each with a live demo
- Design notes
Method
- The baseline is the same React build with the flag off. Both builds come from the PR #24 branch, rebased onto
facebook/reactmain. - Byte figures for the 41 pages were measured at
31b26561f1; later commits on the branch change only the lifetime of unclaimed rows, an error message and documentation. The Waku figures use React60d28f4ee6and Waku62e75f5f. - Byte counts are deterministic for most pages. A few vary slightly between runs because of generated ids, so the last digits can change on a rerun.
- Waku: autocannon with 16 connections for 10 s after a 10 s warm-up, 5 rounds, ratios taken within each round against stock. Chromium 156 for the browser checks.