0%

0000000

0x00

Why a Loading Screen Can't Wait for React to Hydrate

The abchaudary.me loading screen mid-count, showing the percentage, its binary and its hexadecimal readouts over black.

A React-driven loading overlay can't leave before the bundle it hides has hydrated, because that's the same cost it's meant to be covering for.

TL;DR: A loading overlay using useState and useEffect can't dismiss itself before React hydrates, since effects don't run until hydration completes, the exact cost the overlay is hiding. Throttled to Slow 4G and 4x CPU, this site's old preloader covered a painted hero for 17 seconds, and even after fixing readiness still took 7.6 seconds against an 800ms budget. The fix: static HTML torn down by an inline script that runs before hydration starts.

Why can't a React component remove a loading overlay quickly?

A React overlay is gated by the same machinery it's supposed to be hiding: nothing in a component, including an effect meant to dismiss it, runs until React has hydrated, and hydration only starts once the client bundle has downloaded, parsed, and executed (React: hydrateRoot describes hydration as the step that "turns the initial HTML snapshot from the server into a fully interactive app that runs in the browser"). An overlay whose exit condition lives inside useEffect is waiting on the one thing it's covering for.

This site's original preloader was useState plus useEffect, dismissed on window.load, which on the route in question means every image, font, and script finishing, not just the hero. Measured against the production build at Slow 4G and 4x CPU throttling, the median of five runs covered a fully painted, ready-to-read hero for 17 seconds. The overlay's own text was the page's Largest Contentful Paint element, not the hero it was sitting on top of (web.dev: LCP defines LCP as the render time of the largest element visible in the viewport, which is exactly what the counter had become).

Why doesn't fixing the readiness check fix the delay?

Swapping window.load for a real readiness signal, fonts loaded plus any image already in the first viewport decoded, cut the 17 seconds, but it didn't fix the shape of the problem, because the gate was never really the readiness check. It was that a React-driven overlay cannot leave before hydration finishes, and hydration on that same throttled connection takes roughly 7 seconds on its own. Measured again after the readiness fix: 7.6 seconds to remove an overlay carrying an 800ms budget, because the removal itself was still an effect waiting for hydration to complete before it could even begin counting toward that budget.

That's the trap with fixing symptoms one at a time inside a framework component: every fix still inherits the framework's own startup cost, because the fix lives inside the framework.

What I actually shipped

The overlay is server-rendered as static markup, and its entire lifecycle, counting up, checking readiness, fading out, removing itself, is a plain inline script that runs during HTML parsing, before first paint and well before hydration:

function Preloader() {
  return (
    <>
      <script dangerouslySetInnerHTML={{ __html: SKIP_SCRIPT }} />
      <style dangerouslySetInnerHTML={{ __html: OVERLAY_STYLE }} />
      <div id={ROOT_ID} suppressHydrationWarning dangerouslySetInnerHTML={{ __html: OVERLAY_HTML }} />
      <script dangerouslySetInnerHTML={{ __html: SCRIPT }} />
    </>
  );
}

The markup goes in through dangerouslySetInnerHTML rather than JSX children, and that choice is load-bearing, not a style preference: React does not diff or walk the children of an element rendered that way during hydration, so the script is free to rewrite the counter text and later delete the whole node without React ever detecting a mismatch between what it rendered and what's now in the DOM. Rendering the identical markup as JSX children raises React's hydration-mismatch error the moment the script touches it, because React does compare children it owns against the server output (Next.js: hydration error message documents this class of mismatch, including the incorrect-nesting and browser-mutation cases that produce it). Since OVERLAY_HTML's string is a build-time constant, React also never has reason to re-render it on any subsequent update, which is the other half of why the mismatch never fires.

The hard cap on how long the overlay may cover the hero is expressed in CSS, not JavaScript, for the same reason the removal script had to move out of React:

@keyframes preloaderFadeOut { 0%, 85% { opacity: 1 } 100% { opacity: 0 } }
#preloader-root > .preloaderSec { animation: preloaderFadeOut 800ms linear forwards }

A setTimeout-driven fade is scheduled on the main thread, and the main thread on a throttled phone is busy parsing the exact bundle the overlay exists to hide, which is what let the JS-timed version drift to roughly 1.5 seconds against its own 800ms budget. opacity and transform animations run on the compositor thread instead, independent of whatever the main thread is doing (MDN: CSS and JavaScript animation performance confirms these properties can be handled off the main thread). JavaScript can still end the overlay early by rewriting its inline style once real readiness is confirmed; it just can't be the thing keeping the deadline.

On routes that autoplay music, the overlay doesn't dismiss itself at all. It counts to 100 and reveals a Continue button, because starting audio needs a user gesture the browser will actually honor, and that click is it. Every other route is unaffected and still leaves inside the 800ms budget.

Did the fix actually work?

The hero is now the LCP element instead of the overlay's own text, since the overlay never gates render or hydration, it only layers over content that's already painted. Total overlay lifetime is capped at 800ms on the timeline that matters (wall clock from paint), measured the same way as the earlier numbers: Slow 4G, 4x CPU, median of five runs. prefers-reduced-motion: reduce skips construction of the overlay entirely rather than rendering then hiding it, checked before the markup is even parsed.

Where does this fix not apply?

This only works because the overlay's content and behavior are fixed at build time, a counter, three lines of text, one Continue button, none of it depending on props or server data resolved per request. An overlay that needs to render personalized or fetched content can't be reduced to a constant string handed to dangerouslySetInnerHTML, and forcing it to would just relocate the same hydration cost somewhere less visible. For anything data-dependent, the honest fix is reducing what the client bundle has to do before first interaction, not hiding how long it takes behind a nicer-looking wait.

References