Hydration Explained
Hydration is the process of attaching JavaScript interactivity to server-rendered HTML. Learn why it matters, what causes hydration errors, and modern approaches like partial hydration.
What is Hydration?
When a server (SSR/SSG) renders HTML and sends it to the browser, the user sees content immediately. But the page is initially static โ clicks don't work, state doesn't change, event listeners aren't attached.
Hydration is the process where JavaScript:
- Takes over the existing server-rendered DOM
- Attaches event listeners
- Makes the page interactive
Server sends: <button>Count: 0</button> โ static HTML
JS downloads and runs:
React attaches event listener to button โ hydration
Now button works: Click โ Count: 1
Why Not Just Re-render?
You might wonder: why not just have React re-render from scratch?
If React threw away the server HTML and rendered from scratch:
- Flash of content โ the page would briefly go blank
- Layout shift โ content would jump around
- Wasted work โ the server already computed all this HTML
Hydration reuses the existing DOM, matching it against what React would render, and only attaches the necessary event handlers without touching the DOM structure.
The Hydration Process
// SSR: server generates HTML
// ReactDOMServer.renderToString(<App data={data} />)
// โ "<div id='app'><button class='btn'>Count: 0</button></div>"
// Client: hydrate instead of render
import { hydrateRoot } from "react-dom/client";
hydrateRoot(
document.getElementById("app"),
<App data={data} />
);
// React "walks" the existing DOM, matches against the virtual DOM,
// attaches event listeners without touching the structure
Hydration Errors
A hydration mismatch occurs when the server-rendered HTML doesn't match what the client would render. This causes React to re-render from scratch (the very thing we wanted to avoid):
// โ Causes hydration error: Date.now() differs between server and client
export default function Timestamp() {
return <div>{new Date().toLocaleString()}</div>;
}
// โ Math.random() will differ
export default function RandomId() {
return <div id={`item-${Math.random()}`}>Content</div>;
}
// โ Browser-only APIs (window, document) don't exist on server
export default function WindowSize() {
return <div>{window.innerWidth}px</div>; // window is undefined on server!
}
Fixes:
// โ Use useEffect to update after hydration (client-only)
export default function Timestamp() {
const [time, setTime] = useState(""); // empty on server
useEffect(() => {
setTime(new Date().toLocaleString()); // set on client after hydration
}, []);
return <div>{time}</div>;
}
// โ Suppress warning for intentional differences
export default function ClientSideOnly() {
return (
<div suppressHydrationWarning>
{typeof window !== "undefined" ? window.location.href : ""}
</div>
);
}
The Cost of Hydration
Traditional hydration has a problem: the entire page must hydrate before any of it is interactive.
For large pages, this means:
User sees HTML instantly (SSR) โ
Waits while 500KB of JS downloads
Waits while JS parses
Waits while React hydrates entire tree โ blocking
Finally: page is interactive
This gap between "looks interactive" and "is interactive" is known as Time to Interactive (TTI).
Progressive Hydration
Hydrate components incrementally as they enter the viewport:
import { useIntersectionObserver } from "./hooks";
function LazyHydrate({ children }) {
const { ref, isVisible } = useIntersectionObserver();
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
if (isVisible && !hydrated) {
setHydrated(true);
}
}, [isVisible]);
return (
<div ref={ref}>
{hydrated ? children : <div dangerouslySetInnerHTML={{ __html: "..." }} />}
</div>
);
}
Partial Hydration & Islands Architecture
The Islands Architecture (popularized by Astro) takes a more radical approach:
- Most of the page is pure static HTML (zero JavaScript)
- Only explicitly interactive "islands" are hydrated
- Each island hydrates independently
// Astro example:
// Static content โ no JS, just HTML
---
const posts = await getPosts();
---
<h1>Blog</h1>
{posts.map(post => <h2>{post.title}</h2>)}
<!-- Interactive island โ React component, hydrated -->
<SearchBox client:load />
<!-- Visible-only hydration -->
<NewsletterForm client:visible />
<!-- Never hydrates โ static forever -->
<Footer />
Result: 90% of the page is static HTML. Only interactive components ship JavaScript.
React Server Components (RSC)
React 18+ introduces Server Components โ components that run on the server and never ship JavaScript to the client:
// Server Component (default in Next.js App Router)
// Runs on server, contributes to HTML, NO JS sent to client
async function BlogPost({ id }) {
const post = await db.post.findById(id); // direct DB access!
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Client Components can be nested: */}
<LikeButton postId={id} />
</article>
);
}
// Client Component โ sends JS, interactive
"use client";
function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(true)}>
{liked ? "โค๏ธ Liked" : "๐ค Like"}
</button>
);
}
RSC dramatically reduces client-side JS because most components never need to be interactive.
Streaming SSR
React 18's renderToPipeableStream (and Next.js 13+ App Router) streams HTML to the browser as components resolve:
// Shell (header, layout) arrives instantly
// Slow parts (data-fetching) show loading state, then stream in when ready
// Suspense boundary = a place that can stream in later
<Suspense fallback={<Spinner />}>
<SlowDataComponent /> {/* streams in when data is ready */}
</Suspense>
Server starts sending:
<html>...<header>...</header> โ instant
<Suspense><!--placeholder--></Suspense> โ while data loads
Data arrives 300ms later:
Server streams: <SlowDataComponent data={...} />
Browser replaces placeholder without layout shift
Key Takeaways
- Hydration attaches JavaScript to server-rendered HTML to make it interactive
- Mismatch between server and client HTML causes re-render โ avoid
Date.now(),Math.random(), browser APIs in SSR components - Traditional hydration hydrates the whole tree at once โ can cause a long TTI gap
- Islands architecture (Astro) hydrates only interactive components โ less JS shipped
- React Server Components run only on the server โ zero JS sent to client
- Streaming SSR sends HTML progressively as data resolves โ faster perceived performance
Ready to test your knowledge?
Take a quiz on what you just learned.