Skip to main content

React hydration and server-side rendering (SSR)

React hydration - the process where React attaches event handlers and state to server-rendered HTML without rebuilding the DOM. The browser reuses the markup the server created rather than starting from scratch.

Theory

TL;DR

  • SSR is like a restaurant pre-plating your meal. Hydration is the waiter saying "ready to eat." CSR is cooking at your table from raw ingredients.
  • SSR sends finished HTML to the browser immediately (fast first paint), then React wakes it up. CSR sends a blank page and React builds everything from scratch.
  • Hydration fails when server HTML doesn't match what React expects on the client. React re-renders everything when that happens.
  • Use SSR for public sites where SEO and fast first paint matter. Use CSR for internal tools where neither does.

Quick example

tsx
// Server (Node.js + React) import { renderToString } from 'react-dom/server'; const html = renderToString(<App />); // Output: <div><button>Count: 0</button></div> // No event listeners attached yet. // Browser receives and renders HTML immediately. // Then React hydrates: import { hydrateRoot } from 'react-dom/client'; hydrateRoot(document.getElementById('root'), <App />); // React walks the tree, matches existing DOM nodes, attaches onClick. // No new DOM created - the server HTML is reused.

React walks the component tree twice: once on the server to generate HTML, once on the client to match elements and attach handlers. No DOM rebuild.

Key difference: SSR vs CSR

SSR sends a fully rendered HTML string. The browser shows it right away while React downloads in the background. Hydration then wakes up the static markup by attaching event listeners and state. CSR sends an empty HTML shell and React builds the entire DOM from scratch in the browser. Simpler to set up, but slower to first paint.

The trade-off is concrete: SSR costs server CPU on every request. CSR costs the user time on every first visit.

When to use

  • SSR + Hydration: public websites, e-commerce, blogs, SEO-critical content, users on slow networks
  • CSR only: internal dashboards, admin panels, real-time collaborative tools where you control the network
  • Hybrid (Islands): large sites with mostly static content and a few interactive sections. Astro uses this pattern: SSR the static parts, hydrate only the interactive components.

Rendering approach comparison

ApproachWhen HTML is generatedFirst Contentful PaintSEO
CSRIn the browserSlowPoor
SSROn each server requestFastGreat
SSGAt build timeFastestGreat
ISRBuild time + revalidationFast + freshGreat
IslandsServer (static) + client (interactive)FastGreat

How hydration works internally

renderToString(<App />) on the server walks the component tree and produces an HTML string with no event listeners or state. The browser gets this and renders it right away.

When hydrateRoot() runs, React walks the same component tree again. But instead of creating DOM nodes, it compares expected component output to existing DOM elements and attaches handlers, initializes state, and sets up subscriptions. If the two match, hydration finishes quietly. If they don't, React detects a mismatch, logs a warning in development, and falls back to a full re-render.

React 18 added two improvements here. Selective hydration lets different parts of the page hydrate independently via Suspense boundaries. If a user clicks a section that hasn't hydrated yet, React prioritizes it over the others. Streaming SSR sends HTML in chunks as data becomes available, so the browser starts rendering before the full response arrives.

Selective hydration (React 18)

tsx
import { Suspense } from 'react'; function Page() { return ( <div> <Header /> <Suspense fallback={<Spinner />}> <HeavySidebar /> {/* Hydrates independently */} </Suspense> <Suspense fallback={<Skeleton />}> <Comments /> {/* Can hydrate before HeavySidebar */} </Suspense> </div> ); } // If user clicks Comments before it hydrates, React prioritizes it.

Common mistakes

Using dynamic values in render

tsx
// Wrong - server and client produce different timestamps export default function Clock() { const [time, setTime] = useState(new Date().toLocaleString()); return <div>{time}</div>; } // Correct - set time only after mount export default function Clock() { const [time, setTime] = useState(''); useEffect(() => { setTime(new Date().toLocaleString()); }, []); return <div>{time || 'Loading...'}</div>; }

Server renders one timestamp. Browser renders a slightly different one. Mismatch. React re-renders from scratch.

Using browser-only APIs during render

tsx
// Wrong - window doesn't exist on the server, crashes immediately export default function WindowSize() { const [width, setWidth] = useState(window.innerWidth); return <div>Width: {width}</div>; } // Correct export default function WindowSize() { const [width, setWidth] = useState(0); useEffect(() => { setWidth(window.innerWidth); }, []); return <div>Width: {width || 'Loading...'}</div>; }

Fetching user data on the client instead of the server

tsx
// Wrong - server renders "Guest", client renders "Alice" after useEffect export default function UserGreeting() { const [user, setUser] = useState(null); useEffect(() => { setUser(getCurrentUser()); }, []); return <div>Hello {user?.name || 'Guest'}</div>; } // Correct - fetch on the server, pass as prop export async function getServerSideProps() { const user = await getCurrentUser(); return { props: { user } }; } export default function UserGreeting({ user }) { return <div>Hello {user.name}</div>; }

Assuming hydration is instant

tsx
// The user can click a button before React finishes hydrating. // The click fires. Nothing happens. No error visible. // I've seen this pattern lose conversions on e-commerce sites with large JS bundles. export default function Form() { const [hydrated, setHydrated] = useState(false); useEffect(() => { setHydrated(true); }, []); return ( <form onSubmit={handleSubmit}> <button type="submit" disabled={!hydrated}>Submit</button> </form> ); }

Using Math.random() or other non-deterministic values in render

tsx
// Wrong - different value on server and client export default function App() { return <div>{Math.random()}</div>; // Server: 0.123, Client: 0.456 - mismatch } // Acceptable if intentional - suppress only when you understand why export default function App() { return <div suppressHydrationWarning>{Math.random()}</div>; }

Real-world usage

  • Next.js: getServerSideProps triggers SSR; hydrateRoot runs automatically in the browser
  • Remix: all routes SSR by default, hydration is automatic
  • Astro: SSRs static HTML, hydrates only components marked with client:load
  • Gatsby: pre-renders at build time (SSG), hydrates in the browser
  • Express + React: manual SSR with renderToString(), manual hydration with hydrateRoot()
  • SvelteKit: SSRs by default, hydration is automatic

Follow-up questions

Q: What is the difference between hydration and re-rendering?
A: Hydration attaches event listeners to existing DOM nodes without touching the structure. Re-rendering creates new DOM nodes. Hydration is fast because the DOM already exists; re-rendering builds everything from scratch.

Q: What happens when server HTML doesn't match what React expects?
A: React detects the mismatch and falls back to a full re-render in the browser. This defeats the purpose of SSR. In development, you'll see a console warning. Common causes: Date.now() or Math.random() in render, browser-only APIs, different data on server vs client.

Q: Can you hydrate only part of a page?
A: Yes. React 18 calls this selective hydration. Wrap sections in Suspense and React hydrates them independently. Astro goes further with the islands architecture: only components marked client:load get hydrated at all. React Server Components take a different approach: those components never hydrate because they never send JavaScript to the browser.

Q: How do you measure whether SSR actually helps your users?
A: Compare First Contentful Paint (FCP) and Time to Interactive (TTI). SSR improves FCP because users see HTML right away. TTI might not change much because React still needs to download and hydrate. Measure with Web Vitals or real user monitoring tools.

Q (Senior): How would you implement selective hydration for a page where only 10% of components are interactive?
A: Use an islands architecture. Render static components to HTML on the server. Send JavaScript only for the interactive components. Astro does this natively with client:load. In Next.js you can use React Server Components to keep non-interactive components off the client bundle entirely. The goal is reducing JavaScript sent to the browser, not just deferring when it runs. A smaller bundle means faster hydration of the 10% that actually needs it.

Examples

Basic: renderToString and hydrateRoot

tsx
// server.tsx import { renderToString } from 'react-dom/server'; import App from './App'; const html = renderToString(<App />); // Returns: <div data-reactroot=""><h1>Hello</h1><button>Click me</button></div> // No event listeners. No state. Just HTML. // client.tsx import { hydrateRoot } from 'react-dom/client'; import App from './App'; hydrateRoot(document.getElementById('root'), <App />); // React matches components to existing DOM elements. // Attaches handlers. The button is now interactive.

The browser renders the HTML instantly on arrival. hydrateRoot then wires up interactivity without touching the DOM structure. No rebuild.

Intermediate: Next.js product page with SSR

tsx
// pages/products/[id].tsx export async function getServerSideProps({ params }) { const product = await fetchProduct(params.id); return { props: { product } }; } export default function ProductPage({ product }) { const [quantity, setQuantity] = useState(1); return ( <div> <h1>{product.name}</h1> <p>${product.price}</p> <button onClick={() => setQuantity(q => q + 1)}> Add to cart ({quantity}) </button> </div> ); } // Server renders: <button>Add to cart (1)</button> // Browser receives and displays this HTML immediately. // React hydrates: attaches onClick handler. // User clicks: quantity updates because the handler is now wired up.

Product name and price are visible before React loads. The button becomes interactive after hydration. Both come from the same component.

Advanced: Streaming SSR with Suspense boundaries

tsx
// Each Suspense boundary streams independently. // React sends HTML chunks as data becomes available. import { Suspense } from 'react'; export default function ProductPage({ productId }) { return ( <div> <ProductHeader productId={productId} /> <Suspense fallback={<p>Loading reviews...</p>}> <Reviews productId={productId} /> {/* Streams when the reviews query returns */} </Suspense> <Suspense fallback={<p>Loading recommendations...</p>}> <Recommendations productId={productId} /> {/* Streams independently - slow query doesn't block fast one */} </Suspense> </div> ); } // ProductHeader arrives first - user sees it right away. // Reviews and Recommendations stream and hydrate separately. // A slow recommendations query doesn't block the rest of the page.

This pattern means a slow database query for one section doesn't delay the user from seeing or interacting with another. Each Suspense boundary is fully independent.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?