React.lazy silently broke my prerender, and the build still passed

javascript dev.to

I have a React 19 site that prerenders every route to static HTML at build time.
Thirteen page components, seventeen URLs, renderToString in a build script,
one HTML file written per path.

It worked. Then I code-split the pages, and it kept working — in the browser.

The build stayed green. Every page loaded correctly. Nothing threw. It took me
most of a day to notice that every prerendered file was 12.6kB, and that
12.6kB was the header and the footer with nothing in between.

The pages were fine in a browser because React hydrated and rendered the content
client-side, exactly as it would have without any prerendering at all. So the
only symptom was that the build step I'd added specifically to produce static
content had quietly stopped producing it. No error. No warning. Correct-looking
output.

The setup

Before splitting, every page was a static import:

import Home from "@/pages/Home";
import PricingPage from "@/pages/PricingPage";
// ...eleven more
Enter fullscreen mode Exit fullscreen mode

That put all thirteen pages in one bundle. Someone opening the privacy policy
downloaded the pricing comparison table, six blog posts and the sign-in form to
do it. None of it was reachable from that page and none of it could be dropped,
because a static import is a promise that the module is present before the first
line runs.

So: React.lazy.

const Home = lazy(() => import("@/pages/Home"));
Enter fullscreen mode Exit fullscreen mode

And immediately the prerender broke, because a component that isn't loaded yet
has to suspend, and renderToString can't wait.

The fix that didn't work

The obvious answer is to make sure the modules are already loaded before you
render. So I did that — resolved every page's dynamic import up front, awaited
all of them, and only then called renderToString:

await Promise.all(loaders.map((load) => load()));
// every module is now in the registry, every promise resolved
renderToString(<App />);
Enter fullscreen mode Exit fullscreen mode

Registry warm. Promises settled. Nothing left to wait for.

Identical output. Still 12.6kB. Still green.

That's the part that cost me the day, because at that point the theory that
explains everything — "the module isn't there yet" — is provably false. The
module is there.

What's actually happening

React.lazy does not ask whether the module is available. It tracks its own
status, and only its own initialiser advances it.

The sequence, on the very first render:

  1. React encounters the lazy component. Status is Uninitialized.
  2. It calls your loader. It gets back a promise — already resolved, because you preloaded it, but a promise nonetheless.
  3. It marks the component Pending and attaches a .then to write the result back when it settles.
  4. It suspends.

Step 3 is the whole problem. Promise callbacks are microtasks. Even a promise
that is already resolved does not invoke its .then synchronously — it schedules
it, and the scheduled callback runs when the current synchronous execution
finishes and the microtask queue drains.

renderToString is synchronous from top to bottom. It never yields. It never
reaches the next microtask. So it starts the render, hits the lazy component,
sees Pending, suspends, writes the fallback — and returns, all before the
callback that would have marked the component Resolved has any opportunity to
run.

It doesn't matter how resolved your promise is. There is no point during the
render at which React.lazy can observe that fact.

In a streaming renderer (renderToPipeableStream) this is fine, because it can
wait. In renderToString it is unfixable from the outside, because the state
you need to influence isn't yours.

The fix

Stop delegating the state. Hold the module yourself, so "is it loaded" is a
question you can answer synchronously:

import { use, type ComponentType } from "react";

const page = (path: string | null, load: Loader, src: string): RouteObject => {
  let Loaded: ComponentType | null = null;
  let pending: Promise<unknown> | null = null;

  const preload = () => (pending ??= load().then((m) => (Loaded = m.default)));

  const Page = () => {
    if (!Loaded) use(preload());
    const Resolved = Loaded!;
    return <Resolved />;
  };

  loaders.push(preload);
  return {
    ...(path === null ? { index: true as const } : { path }),
    element: <Page />,
    handle: { preload, src },
  };
};
Enter fullscreen mode Exit fullscreen mode

Loaded is a plain variable that the .then assigns. There are exactly two
states and no microtask between them:

  • Loaded is set — rendering is a synchronous function call. Nothing suspends. This is the path the build takes, every time.
  • Loaded is nulluse() suspends, exactly as lazy would, which is what you want in the browser when someone navigates to a page that hasn't been fetched.

use rather than a thrown promise because it's the supported spelling in React
19, and unlike hooks it's allowed inside a condition.

The build then settles everything once, up front:

export const loadAllPages = () => Promise.all(loaders.map((load) => load()));
Enter fullscreen mode Exit fullscreen mode

After that call, every Loaded is populated, and every subsequent render is the
same synchronous call it was back when the imports were static. Routes went from
12.6kB of chrome to actual prerendered content, and the split bundles stayed
split.

preload is also memoised on pending, so it's the same promise however many
times it's asked — which lets the same function double as a hover-prefetch on
links without ever fetching twice.

The part worth generalising

The bug itself is narrow. The failure shape is not.

Nothing errored. The build exited zero. The dev server was perfect. The
production site was perfect, because the client quietly redid the work the build
had failed to do. Every signal I had said success, and the only way to see the
problem was to look at the size of an output file and ask why it was suspiciously
round.

Any time you add a build step whose output something else can silently
reconstruct at runtime, you have created this trap. The runtime covers for the
build, and the build is free to stop working. Now I assert on the artifact — the
prerender step fails the build if a route's HTML doesn't contain a string it
must contain.

A green build is not evidence. The artifact is evidence.


This came out of a landing-page template I've been building — one React 19 +
Vite codebase that ships as five complete brand identities, all seventeen routes
prerendered. The five live demos are here, and
if that's useful to you, it goes on sale
shortly
.

Source: dev.to

arrow_back Back to Tutorials