Skip to main content
๐Ÿ–ฅ๏ธintermediate

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:

  1. Takes over the existing server-rendered DOM
  2. Attaches event listeners
  3. 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

hljs javascript
// 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):

hljs javascript
// โœ— 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:

hljs javascript
// โœ“ 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:

hljs javascript
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
hljs javascript
// 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:

hljs javascript
// 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:

hljs javascript
// 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
โ–ถTry it yourself

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.

Take the Quiz โ†’