Minimal reproduction of a bug where prerendered routes ship the Suspense fallback
in the initial shell with the actual content emitted as a hidden <template> +
inline $RC() swap script. When the static HTML is served at runtime, the
browser paints the fallback first, then runs the swap script — producing a
visible "fallback flash" on initial paint.
The Suspense boundary's children render fully synchronously (nothing actually
suspends). The flash is purely an artifact of React's streaming-SSR
progressiveChunkSize outlining heuristic being applied to a non-streaming
consumer (the prerender pipeline buffers the response to disk via
response.text()).
bun install
bunx react-router buildOpen build/client/index.html. Search for SUSPENSE FALLBACK:
grep -c "SUSPENSE FALLBACK" build/client/index.html
# => 2 (one in the shell as the fallback, one in the bundled JS)The body opens with React's deferred-Suspense markup — the fallback in the
shell, the actual route content in a hidden <div id="S:0">, and an inline
<script>$RC("B:0","S:0")</script> that swaps them at runtime:
<body>
<!--$?--><template id="B:0"></template>
<div style="...">⚠ SUSPENSE FALLBACK</div>
<!--/$-->
...
<div hidden id="S:0">
<main><h1>HOME PAGE</h1>...actual content...</main>
</div>
<script>$RC("B:0","S:0")</script>
</body>To see the flash visually, serve the file:
bunx react-router-serve ./build/server/index.js
# or any static server pointed at build/client/…then open the page in a browser. On a fast machine the flash is brief but visible (LCP comes after the swap).
React's renderToPipeableStream "outlines" a <Suspense> boundary when the
rendered HTML is about to exceed progressiveChunkSize (default 12,800
bytes). Outlining writes the fallback into the shell and emits the actual
content later as <template> + inline $RC() swap. The point of the
optimization is to flush the shell early so the browser can start parsing /
painting while the rest of the response streams in.
For prerendered routes there is no streaming. prerenderRoute calls the user's
handleRequest and does await response.text(), capturing the entire stream
into a string written to disk. The early-flush "win" buys nothing — the
consumer is buffering the whole thing anyway. But the outlining decision is
made during the render and bakes the shell-with-fallback + swap-template
structure into the static file. Serving it later means the browser still
paints the fallback first.
The 12.8 KB threshold is easy to trip in real apps:
- The
<head>includes<link rel="modulepreload">for every route module on the path;v8_splitRouteModules: 'enforce'makes that worse (every route export is its own chunk). - A top-level
<Suspense>is a common composition pattern (e.g. with TanStack Query's<HydrationBoundary>and any descendantuseSuspenseQuery). - The bug is content-sensitive in surprising ways — adding or removing a few bytes inside the boundary can flip whether outlining occurs, since it tips byte totals across the threshold.
react-router's prerender constructs its synthetic Request with no
user-agent header. You can detect that in app/entry.server.tsx and
disable outlining only for prerender:
return new Promise((resolve, reject) => {
let shellRendered = false;
let userAgent = request.headers.get("user-agent");
let isPrerender = !userAgent;
let readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
// ...
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
progressiveChunkSize: isPrerender ? Infinity : undefined,
[readyOption]() {
/* ... */
},
// ...
},
);
});Runtime SSR keeps its streaming behavior; prerender renders content inline, no flash.
To verify the workaround:
bunx react-router reveal entry.server
# apply the diff above, then:
bunx react-router build
grep -c "SUSPENSE FALLBACK" build/client/index.html
# => 1 (only in the bundled JS — no longer in the rendered HTML shell)react-router could pass a header on its synthetic prerender requests
(e.g. X-React-Router-Prerender: 1), and the default entry.server.tsx
template could check for it and pass progressiveChunkSize: Infinity when
present. Or expose a routerContext.isPrerender flag, similar to the
existing routerContext.isSpaMode.
react-router: 7.15.0react-dom: 19.2.x- Node.js: 24.14.1
- Bun: 1.3.x
- macOS
The bug also reproduces on react-router 7.13.0.