What you'll learn: By the end of this lesson you will be able to explain what hydration does to server-rendered HTML and why it costs main-thread time, describe how React 19 hydrates a page boundary by boundary and gives priority to the part a visitor touches, say what selective hydration does not save, explain how an islands architecture hydrates only the interactive parts of a page, and choose between whole-page hydration, Suspense boundaries, islands and Server Components for a given page.
Imagine moving into a flat that is already furnished. The lamps are in place and the room looks finished, but the electricity is not connected: every switch does nothing until an electrician traces each wire to its fitting. An electrician who starts at the front door and works through every room leaves you in the dark in the bedroom you wanted first. A smarter one asks which room you are standing in.
A server-rendered web page is that flat. The HTML shows the finished page, but none of the buttons respond until JavaScript has connected them. That step is hydration. This lesson covers what it does, what it costs, and three ways of paying less: wiring the visitor's room first (selective hydration), not wiring the static rooms at all (islands), and not shipping the wiring plan for some rooms (Server Components, which have their own lesson).
The running example is a product page for a trail shoe: a gallery, a description, an "Add to cart" button, reviews with a "Helpful" button, and recommendations with a "Save" button. The lab uses exactly this page.
The core idea
Four statements carry the lesson.
- Server-rendered HTML is not interactive. It is markup. Until JavaScript runs, a click does nothing.
- Hydration makes it interactive by running your components in the browser again and attaching them to the existing DOM. The markup is reused; the work of rendering is repeated.
- The cost is paid on the main thread, the same thread that handles taps and scrolls. How much you pay depends on how much of the page is hydrated and when.
- There are three levers. Hydrate in smaller units, so the touched part goes first. Hydrate fewer parts, so static parts never run. Send no code for parts that need no behaviour.
The names are often mixed up, so each is defined where it first appears. Version note: at the time of writing, react.dev lists React 19.3 as the latest version, and the lab ran React 19.3.1.
Why a ready-looking page can ignore you
Take the product page rendered by a server, as in Rendering Strategies: CSR, SSR, SSG, ISR and Streaming. The browser paints the HTML and downloads JavaScript in parallel, so for a while the page looks complete and is not. Figure 1 shows the two paths that must meet.
Figure 1. The two paths that meet in hydration
How to read this: the HTML produces the page you can see; the JavaScript produces the behaviour. Hydration joins them, reusing the DOM nodes that already exist.
react.dev defines hydrateRoot as the call that lets you "display React components inside a browser DOM node whose HTML content was previously generated by react-dom/server". The Next.js documentation describes hydration as attaching event handlers to the DOM, and says a Client Component on a direct visit renders on the server and again in the browser during hydration. That repeat is where the cost comes from.
web.dev names three costs of rehydration. Download and run the JavaScript for components whose output is already on screen. Do it on the main thread: web.dev states that rehydration "can have a significant negative impact on TBT and INP, even if it improves FCP" (TBT is total blocking time, a lab measure of main-thread blocking; INP is Interaction to Next Paint, which INP and Responsiveness covers). And send the data twice, once as markup and once as the data the components need.
Figure 2. One page load, with the gap that hydration closes
How to read this: the first click lands in the gap between "painted" and "hydrated" and is not handled. This lesson is about how React fills that gap.
What hydrateRoot expects
The hydrateRoot reference states the contract: it "expects the rendered content to be identical with the server-rendered content. You should treat mismatches as bugs and fix them." With no server HTML, createRoot is the right call. If root.render is called before hydration has finished, React clears the server-rendered content and switches the whole root to client rendering. What happens when the contract is broken is the subject of Debugging Hydration Mismatches.
Three waits, and what React 18 changed
Before React 18, hydration was all or nothing. The React working group's note on the new Suspense SSR architecture lists three problems: you had to collect all the data on the server before sending any HTML ("fetch-first"), load the JavaScript for all components before hydrating any of them ("load-first"), and wait for all components to be hydrated before interacting with any of them ("hydrate-first").
Streaming HTML addresses the first wait (Streaming SSR and Suspense covers it). The same Suspense boundaries address the other two on the client: the react.dev Suspense reference says streaming server rendering and selective hydration are "integrated with Suspense", and points to the working group note, the main source for the next section.
Selective hydration
A Suspense boundary is the unit of hydration. Wrap the reviews section and the recommendations section in their own boundaries and React no longer has to hydrate the page in one piece. The page in the lab is built like this, using createElement so that Node can run the file:
boundaries ? h(Suspense, { fallback: h("p", null, "Loading reviews...") }, reviews) : reviews,
boundaries ? h(Suspense, { fallback: h("p", null, "Loading recommendations...") }, recs) : recs,
What the working group note says about how React then behaves:
- Hydration can start for the parts whose code has loaded, without waiting for the rest.
- React prioritises the part of the screen the user interacts with. Parent boundaries on the path hydrate first; unrelated siblings are skipped for now.
- On a click during hydration, React hydrates the clicked content synchronously during the capture phase of the click event, then the rest.
- Hydration is parent-first, so each component has its props set.
- If a boundary's code has not arrived, it keeps showing the server HTML.
The note also says React held back events aimed at a part of the tree that was not hydrated yet, and a later reply says replaying events was removed for most cases. It gives no rules for continuous events such as scroll, so this lesson makes no claims about them.
Figure 3. A click on the second boundary, in the order the lab recorded
How to read this: nothing is hydrated when the click arrives. React hydrates only what the click needs (the shell above it and the boundary it landed in), handles the click, and leaves the other boundary for later.
What the lab shows
The lab renders the page on the server, parses it in jsdom (a JavaScript model of the DOM, not a browser) and calls hydrateRoot. Every component logs a line when React calls it. The full output is in "Build it".
| Situation | Component calls, in order | Handler and what follows |
|---|---|---|
| Nobody interacts | gallery, description, cart, reviews, recs | no click; nothing runs until the first event loop turn |
| Click on Save, with boundaries | gallery, description, cart, recs | handler runs; reviews is hydrated afterwards |
| Click on Helpful, with boundaries | gallery, description, cart, reviews | handler runs; recs is hydrated afterwards |
| Click on Save, no boundaries | gallery, description, cart, reviews, recs | handler runs after all five |
The scheduling order is React's decision, not an API: it is what React 19.3.1 did.
When the code has not arrived
In one scenario the reviews and recommendations are React.lazy and their code arrives only when the scenario says so:
- The shell hydrates and the cart works while both boundaries are still plain server HTML.
- A click on the reviews button does nothing. When the reviews code arrives, the boundary hydrates, and the earlier click is not replayed (the count stays at 0). This is observed in the lab; it is not documented on react.dev.
- A boundary whose code never arrives never becomes interactive, and the page does not break: no error is reported and its DOM nodes are still the server's.
This is the resilience argument for boundaries: one slow chunk delays one section, not the page.
What selective hydration does not do
Selective hydration reorders and splits the work. It does not remove it.
- Every component still runs once in the browser. The page without boundaries makes five component calls; with boundaries it makes the same five, in a different order.
- Static parts run too. The gallery and the description have no state and no handlers, but React cannot know that.
- The JavaScript is still downloaded. Code splitting shrinks the bundle (see Code Splitting, Lazy Loading and Tree Shaking); splitting hydration does not.
- The benefit is responsiveness. The Next.js streaming guide says that with boundaries "hydration is broken into smaller tasks that yield to the browser", and that without them React hydrates the page in one blocking pass. The lab counts calls and does not measure time, so it cannot confirm the size of that effect.
The next two ideas attack "every component runs" directly.
Islands
An islands architecture renders most of a page as plain HTML and adds small interactive parts, the islands, that hydrate separately. Astro's documentation defines a client island as "an interactive JavaScript UI component that is hydrated separately from the rest of the page", and says client-side JavaScript is loaded only for components you mark with a client:* directive: client:load (interactive on load), client:idle (load when the browser becomes idle) and client:visible (load once it enters the viewport). Each island runs in isolation, so a heavy, low-priority island does not block a high-priority one, and islands can still share state.
The lab builds a small stand-in for such a loader, written for this lesson and not Astro's code. The server renders the static parts as markup and each interactive part as its own root; the element carries its strategy and props:
const inner = renderToString(h(def.component, { log, ...props }), {
identifierPrefix: prefixOf(def.name, options.usePrefixes),
});
In the browser, the loader waits for the island's trigger, loads its code and gives that element its own React root:
runtime.roots.push(
hydrateRoot(element, h(def.component, { log: options.log, ...props }), {
identifierPrefix: prefixOf(name, options.usePrefixes),
onRecoverableError: () => {
runtime.recoverableErrors += 1;
},
}),
);
Figure 4. A page of static HTML with three islands
How to read this: the static parts are never given to React in the browser. Each island waits for its own trigger and gets its own root, so one island's slow code or mismatch affects only that island.
What the lab shows about islands
- Three component calls, not five. One root makes five calls; as islands, only the three island components run. The gallery and the description never run in the browser.
- An island is inert until its trigger fires. A click on the reviews button before its island hydrates does nothing; after the "visible" trigger it works.
- Islands are independent. An island whose code never arrives does not stop the others, and a mismatch inside one island rebuilds only that island.
- Several roots share one document.
useIdgives the same id for the same tree position in two roots, so two islands can collide.identifierPrefix, the same value on the server renderer andhydrateRoot, fixes it; the useId reference documents it for multiple apps on one page.
The costs of islands
Islands move work; they do not make it free. Two islands have no common React parent, so shared state needs an external store, the URL or an event, and context does not cross roots. Each island's props are serialised into the HTML. A framework with first-class islands hides most of this; hand-rolled islands do not.
Partial and progressive hydration, and Server Components
web.dev's Rendering on the Web defines progressive rehydration as booting pieces of a server-rendered application over time, and partial rehydration as finding the pieces with little interactivity and turning their JavaScript into inert references, which it notes has proven hard to implement. In those terms, selective hydration fits the description of progressive rehydration (parts booted over time), and islands are one way of doing partial hydration. React Server Components are a third answer: components that run only on the server, so their code is not sent to the browser at all. React Server Components has the full treatment.
Choosing
Figure 5. Choosing how much of the page to hydrate
need JavaScript behaviour?"] -->|"no"| B["Ship HTML only:
nothing to hydrate"] A -->|"yes"| C{"Is most of the page
interactive?"} C -->|"yes"| D["One root, with Suspense
boundaries around slow or
low-priority parts"] C -->|"no, a few parts"| E{"Does your framework
support islands or
Server Components?"} E -->|"islands"| F["Islands: hydrate only
the interactive parts"] E -->|"Server Components"| G["Server Components plus
small Client Components"] E -->|"neither"| D
How to read this: start from how much of the page needs behaviour. Boundaries improve a page that must be one app; islands and Server Components pay off when most of it is static.
| Approach | Browser runs | Priority on interaction | Costs | Fits |
|---|---|---|---|---|
| One root, no boundaries | every component, in one pass | none | simplest | small apps |
| One root with boundaries | every component, split by boundary | per boundary | boundary placement, fallbacks, layout shifts | interactive apps with slow sections |
| Islands | only the islands | per island, by trigger | shared state needs another mechanism | content pages with a few widgets |
| Server Components | only Client Components | per boundary | a framework and a bundler | pages that read server data and have little behaviour |
Three illustrations
Illustration 1, the minimal example. Server HTML, then hydrateRoot: the lab's no-boundaries-click-recs scenario. One root, five calls, a click handled after all of them. Before hydration the button exists and does nothing; afterwards the same DOM node responds.
Illustration 2, the realistic example. The product page with two boundaries and the click on "Save" (Figure 3). The page stays one app, the click jumps the queue, and the untouched reviews wait.
Illustration 3, the failure and edge case. A boundary whose code never arrives is safe and silent. Two islands with colliding ids are not: in the lab's ids-without-prefixes scenario the document contains the same id twice. MDN says an id "must be unique within the entire document", and for <label for> the first element in the document with that id is the labelled control, so a label in the second island can point at a control in the first.
Build it: watch React hydrate in pieces
Goal. Predict, then read, which components React runs, and in which order, when the page is hydrated as one root, with Suspense boundaries, with late code, and as islands.
Setup. The lab is in the course's example workspace. It needs Node.js (it ran on 26.12.1, which runs TypeScript directly) and pnpm.
cd examples/frontend
pnpm install
cd labs/hydration-selective-islands
pnpm test # 16 tests
node src/demo.ts # prints the traces
Steps.
- Read
boundaries-click-recs. Before you run anything, write down which components you expect before the handler. - Compare it with
no-boundaries-click-recs. What is the only difference in the page? - In
islands-by-trigger, find the step where a click does nothing, and the step after which it works.
Expected output for three scenarios, copied from a real run (Node 26.12.1, React 19.3.1, jsdom 31.0.0, 2026-11-08). Buttons are, in order, cart, helpful and save; "components run" lists what React called since the previous step.
=== boundaries-click-recs
note: Two Suspense boundaries; the visitor clicks Save (the second boundary) first.
-- click on the recs button, right after hydrateRoot and before React has rendered anything
components run: render gallery, render description, render cart, render recs, handler recs
buttons: Add to cart (0) | Helpful (0) | Save (0)
-- the event loop runs
components run: render reviews
buttons: Add to cart (0) | Helpful (0) | Save (1)
buttons that are still the server's DOM nodes (cart, helpful, save): true, true, true
recoverable errors: 0
=== no-boundaries-click-recs
note: No Suspense boundaries; the visitor clicks Save first. Compare with boundaries-click-recs.
-- click on the recs button, right after hydrateRoot and before React has rendered anything
components run: render gallery, render description, render cart, render reviews, render recs, handler recs
buttons: Add to cart (0) | Helpful (0) | Save (0)
-- the event loop runs
components run: (none)
buttons: Add to cart (0) | Helpful (0) | Save (1)
buttons that are still the server's DOM nodes (cart, helpful, save): true, true, true
recoverable errors: 0
=== islands-by-trigger
note: cart is client:load-like, reviews waits to be visible, recs waits for idle. The gallery and description never run in the browser.
-- page loaded: only the 'load' island hydrates
components run: render cart island
buttons: Add to cart (0) | Helpful (0) | Save (0)
-- click on the reviews button (its island is not hydrated)
components run: (none)
buttons: Add to cart (0) | Helpful (0) | Save (0)
-- the reviews element becomes visible
components run: render reviews island
buttons: Add to cart (0) | Helpful (0) | Save (0)
-- click on the reviews button again
components run: handler reviews
buttons: Add to cart (0) | Helpful (1) | Save (0)
-- the browser goes idle
components run: render recs island
buttons: Add to cart (0) | Helpful (1) | Save (0)
islands hydrated, in order: cart, reviews, recs
component calls in the browser: 3
buttons that are still the server's DOM nodes (cart, helpful, save): true, true, true
recoverable errors: 0
ids used more than once in the document: none
Reading it: with boundaries the click on Save hydrates the shell and the second boundary and runs the handler; render reviews comes afterwards. Without boundaries, every component runs first. In the islands run the gallery and description never appear, and there are three calls, one per island.
This lab is not a browser. The server half is real React on real Node. The browser half is jsdom, which does no layout, paints nothing, and has no IntersectionObserver or network. The lab shows order, counts and which DOM nodes survive; it does not show how long hydration takes, what a screen looks like, or how a browser schedules work, and it ran on one React and one Node version. "Visible", "idle" and "code arrives" are triggers the scenario fires.
Break it. In src/page.ts, in ProductPage, replace the two boundaries ? h(Suspense, ...) : x lines with just reviews, and recs,. The page has no boundaries, so six tests fail: the two click-priority checks and the four code-not-loaded checks that depend on a boundary. Revert it.
Stretch. Nest a "photos" boundary inside the reviews and predict the hydration order for a click inside it before you run it.
In practice: a product page assembled from editorial blocks
This is a realistic scenario, not a case history.
The situation
An online shop's product page has a fixed core (gallery, price, add to cart) and a content area that an editorial team fills from a CMS. Two blocks are interactive widgets: a purchase panel with a quantity field and a gift-note box. The whole page is one React app that is server-rendered and hydrated. The page paints quickly, and field data shows that taps on "Add to cart" in the first seconds are sometimes slow to respond on mid-range phones.
Starting architecture
Figure 6. The page today: one root hydrates everything
How to read this: every component, including the static editorial text, is run by one hydration pass on the main thread, and a tap on "Add to cart" waits behind it.
Options considered
| Option | What changes | Main risk |
|---|---|---|
| One root, add Suspense boundaries | the core hydrates first | editorial text is still hydrated, only later |
| Islands for the two widgets | editorial text is plain HTML; each widget has its own root | shared state and ids across roots; a loader to maintain |
| Server Components | editorial text and reviews run on the server only | a framework change; a large migration |
The decision
Boundaries first, islands for the two widgets second. Boundaries are a small change inside the existing app. The editorial blocks are mostly static text, so they are the best island candidates, and the page already assembles them from independent pieces. Defer Server Components: one page does not justify the migration. Revisit if field data still shows slow first taps after both steps.
Rollout and verification
- Add the boundaries. Test, as the lab does, that a click on the core button is handled before the reviews have hydrated and that no mismatch is reported.
- Move the two widgets to separate roots behind a loader with
load,visibleandidletriggers, each root with anidentifierPrefixthat is the same on the server and in the browser. - Add a test that fails when the same
idappears twice in the rendered page. - Watch field INP for the page's route, by device class, before and after each step. A lab trace says nothing about real phones.
The 2 a.m. failure
The day after the islands release, customers report that tapping the "Gift note" label puts the cursor in the "Quantity" field. Both widgets call useId for their input, at the same position in their trees. The two widgets were moved to islands without an identifierPrefix, so both roots produced the same id, the page has duplicate ids, and the browser resolves <label for> to the first element with that id. React reported nothing, because hydration matched. The fix is a prefix per root on both sides; the guard is the duplicate-id test. The lab's ids-without-prefixes scenario reproduces the collision.
Architect's notes
- Decide the unit of hydration early. Boundaries are cheap at design time and awkward to retrofit, because they change where fallbacks and layout shifts appear.
- Make the first interaction the priority. Put the controls most visitors reach for (add to cart, search, navigation) in the shell or a boundary of their own, and slow, low-priority sections in boundaries that can wait.
- Count work, then measure time. Component calls say which parts could be removed; only field data says whether it matters.
- Prefer less code over smarter hydration. If a part has no behaviour, the best hydration is none.
- Treat islands as a boundary of ownership. Shared state, ids and styling across roots need an explicit design.
Common mistakes
- Assuming server-rendered means interactive. The page paints before it works; design for taps that go nowhere.
- One big boundary. A single boundary is a single hydration unit: the old behaviour with extra steps.
- Expecting boundaries to cut JavaScript. They split work; code splitting and not shipping code cut bytes.
- Hydrating static content that has no state and no handlers.
- Forgetting that islands are separate roots. Context does not cross them, ids can collide, and a prefix must match on both sides.
- Relying on event replay. In the lab a click on an unhydrated boundary was not replayed.
- Benchmarking in jsdom. It shows order and counts, not time.
Key takeaways
- Hydration runs components again in the browser and attaches them to the existing DOM; it is the price of every server-rendered strategy.
- It costs download, main-thread time and duplicated data, and a page can look ready before it responds.
- A Suspense boundary is a unit of hydration: React hydrates boundary by boundary, parent first, and prioritises what the visitor touches.
- Selective hydration reorders work; every component still runs once, static ones included.
- A boundary whose code has not arrived stays as server HTML while the rest works; early clicks were not replayed in the lab.
- Islands hydrate only the interactive parts, each with its own root and trigger.
- Several roots need an
identifierPrefix, the same on both sides, oruseIdvalues collide. - Pick by how much of the page is interactive: boundaries for app-like pages, islands or Server Components for mostly static ones.
Practice exercises
Exercise 1, easy (20 minutes). Run the lab and write down the order of component calls for boundaries-idle and boundaries-click-reviews. Explain each difference in one sentence.
Exercise 2, intermediate (45 minutes). Add a boundary around the cart button and predict, then check, what a click on "Save" before hydration does. Update the tests.
Exercise 3, intermediate (60 minutes). Add a fourth island with its own useId field. Predict whether the ids collide with and without a prefix, then extend the duplicate-id test.
Exercise 4, advanced (2 hours). Take a page you know, mark which components have state or handlers, and sketch the designs of Figure 5. Say what each would hydrate and which metric you would watch.
Related reading
- Rendering Strategies: CSR, SSR, SSG, ISR and Streaming: where hydration sits among the strategies, and the caching side.
- Choosing a Rendering Strategy per Page: the per-page decision this lesson refines.
- Streaming SSR and Suspense: how the boundaries stream from the server.
- React Reconciliation and the Virtual DOM: why the client tree must match for state and DOM nodes to be kept.
- Debugging Hydration Mismatches: what React does when the browser's first render differs.
- React Server Components: sending less code by not running components in the browser.
Official documentation
- hydrateRoot (react.dev): what it does, options, the "identical" contract.
- New Suspense SSR Architecture in React 18 (React working group): the three waits and selective hydration.
- Suspense (react.dev) and renderToString (react.dev): streaming, selective hydration and what renderToString does with a suspended component.
- useId (react.dev): ids and
identifierPrefixfor several roots. - Islands architecture (Astro docs): client islands and the
client:*directives. - Rendering on the Web (web.dev): the cost of rehydration, progressive and partial rehydration.