Back to News & Insights
Web Development September 1, 2026 · 4 min read

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

I have a React 19 site that prerenders every route to static HTML at build time. Thirteen page...

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

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.

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.

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.

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

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:

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.

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: React encounters the lazy component. Status is Uninitialized. It calls your loader. It gets back a promise — already resolved, because you preloaded it, but a promise nonetheless. It marks the component Pending and attaches a .then to write the result back when it settles. 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.

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

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 null — use() 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.

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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation