There's a window between when a Next.js page finishes painting and when users can actually interact with it. Buttons look clickable. Links look followable. Nothing responds. The page is there — the HTML arrived, the browser rendered it — but the JavaScript hasn't finished running yet.
That window is hydration. And most of the JavaScript your app sends to the browser exists to get through it.
What hydration costs
The server renders HTML and sends it. The browser paints it immediately — that's your LCP. Then the browser downloads your JS bundle, parses it, compiles it, and runs it. React walks the entire DOM, compares it to the virtual DOM, and attaches event listeners to every interactive element. Only then is the page actually usable.
The time between LCP and Time to Interactive is the hydration tax. On a fast connection and a modern laptop it's barely noticeable. On a mid-range Android phone on 4G, it can be two to four seconds.
To see your own numbers: in Chrome DevTools Performance panel, record a page load and find the gap between the LCP marker and the point where long tasks stop. That gap is roughly what hydration is costing your users.
The use client boundary problem
Next.js App Router lets you keep components on the server by default. The moment you add use client, that component and everything it imports gets bundled for the client and hydrated.
The mistake I see most often is adding use client to a layout or wrapper component because one child needs it:
// This ships the entire subtree to the client
'use client';
import { Header } from './Header';
import { StaticContent } from './StaticContent'; // doesn't need to be a client component
import { InteractiveWidget } from './InteractiveWidget'; // this is the only reason for 'use client'
export function PageLayout({ children }) {
return (
<>
<Header />
<StaticContent />
<InteractiveWidget />
{children}
</>
);
}
StaticContent and Header are now client components. They'll be included in the JS bundle and hydrated, even if they never use state, effects, or event handlers.
The fix is to push use client as far down the tree as possible — to the component that actually needs it:
// Server component — no bundle cost, no hydration
import { Header } from './Header';
import { StaticContent } from './StaticContent';
import { InteractiveWidget } from './InteractiveWidget'; // 'use client' lives inside this file
export function PageLayout({ children }) {
return (
<>
<Header />
<StaticContent />
<InteractiveWidget />
{children}
</>
);
}
InteractiveWidget still ships to the client because it needs to be interactive. But Header and StaticContent stay on the server. Their HTML arrives pre-rendered and nothing hydrates them.
This is the single highest-leverage change in most Next.js App Router migrations I've seen. Auditing use client placement before reaching for any other optimization is worth doing first.
Finding what's actually in your bundle
The Next.js bundle analyzer shows what's in each chunk. Add it to next.config.js:
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
Run ANALYZE=true npm run build and look at the client bundle. Two things to look for: large dependencies that are imported inside use client components but don't actually need browser APIs, and server utilities that accidentally got pulled into the client graph.
A date formatting library that ended up in the client bundle because it was imported in a use client component that could have been a server component — I've found this more than once. Moving the component to the server removes the library from the client bundle entirely.
Code splitting reduces bundle size, but it doesn't help if what you're splitting is still fully hydrated. Smaller chunks load faster; that's real. But if every chunk is loaded and hydrated on every page, the gain is limited.
Deferring hydration for below-the-fold content
Content the user can't see immediately doesn't need to be hydrated immediately. React's lazy with Suspense defers both the download and the hydration:
import { lazy, Suspense } from 'react';
const HeavyDashboardWidget = lazy(() => import('./HeavyDashboardWidget'));
export function Dashboard() {
return (
<div>
<AboveTheFoldContent />
<Suspense fallback={<WidgetSkeleton />}>
<HeavyDashboardWidget />
</Suspense>
</div>
);
}
The widget's JavaScript isn't downloaded or hydrated until React renders that Suspense boundary. For content far down the page, wrapping it in a lazy-loaded component with an IntersectionObserver trigger defers hydration until it's actually scrolled into view — though this adds complexity that's only worth it for genuinely heavy components.
The simpler version — just using lazy without the intersection observer — already helps because the browser prioritizes loading and hydrating above-the-fold content first.
What this looks like in production
LCP and TTI moving in opposite directions is a signal that hydration cost has gotten out of hand. LCP improves because you're serving fast HTML from the server. TTI stays high because you're still sending a large client bundle to hydrate it.
Measuring LCP is straightforward. TTI is harder — it's not a Core Web Vital and it doesn't appear in CrUX. The proxy metric is INP on early interactions: if users are clicking buttons within the first few seconds of loading and seeing high INP, the page probably wasn't fully hydrated yet when they tried.
The next article looks at what Server Components actually change about the bundle and hydration picture — and where they don't help as much as the marketing suggests.
Top comments (0)