Hydration (SSR vs Interactive)
the myth that SSR makes your page interactive faster
Web developmentweb development / performance
The myth
"We moved to Next/Nuxt/SvelteKit. The HTML arrives already rendered, so users can use the page sooner."
Actually
SSR makes your page VISIBLE sooner. It does nothing for INTERACTIVE — and can push it later than a plain SPA.
The server sends HTML, the browser paints it, buttons appear. But zero event listeners are attached. The framework still has to:
- download the JS bundle
- parse + execute it
- re-render the whole component tree in memory
- walk the real DOM, attach listeners, assert markup matches
Steps 3–4 are hydration. Until they finish, your page is a screenshot.
The uncanny valley
FCP at 0.6s, hydration done at 3.4s. For 2.8 seconds the user sees a finished UI and taps things that do nothing. Those clicks are dropped, not queued. A spinner would have been honest; SSR shows a lie.
And hydration is CPU work on the single main thread. A mid-range Android is 4–6x slower than your laptop: 300ms of hydration on your MacBook is 1.5s+ on the device your users actually hold.
Prove it in 30 seconds
DevTools → Performance → CPU throttle 4x → reload. Look for the long "Evaluate Script" block and the long task sitting AFTER first paint. Or instrument it:
// root component
useEffect(() => console.log('hydrated', performance.now()), []);
Compare that number to your FCP. The gap is the lie window. Track INP, not Lighthouse's score — a 98 is mostly a load-speed measurement.
What actually trips people
- Hydration cost scales with COMPONENT COUNT, not page weight. Compressing images doesn't move it at all.
- A hydration mismatch (Date.now(), localStorage, locale-formatted numbers during render) makes React discard the server HTML and re-render on the client — you paid for SSR and shipped a slower CSR.
- Fetching above-the-fold data in useEffect adds a whole round trip AFTER hydration finishes.
Fixes, in order of leverage
- Ship less JS: Server Components / islands render markup server-side with zero client bundle.
- Hydrate less: next/dynamic with ssr:false, or Astro's client:visible, for below-the-fold widgets.
- Stream it: React 18 + <Suspense> hydrates selectively, and a click on a not-yet-hydrated boundary jumps that boundary to the front of the queue.
- Different model entirely: Qwik "resumes" from serialized state instead of replaying the render — no hydration pass at all. Worth knowing the idea even if you never adopt it.
The one-line mental model
SSR buys you pixels. Hydration buys you behavior. They are separate purchases, and you pay for the second one in main-thread milliseconds on someone else's phone.
Your exercise
Take your heaviest page. CPU throttle 4x, record from FCP to the first click that actually works — write down the ms. Then list your 3 biggest hydrated components and ask each: "does this need to be interactive on load?" Move one behind a lazy/no-SSR boundary and re-measure. What was the delta?