Appearance
6.5.1 — Rendering Strategies & Hydration
Two product pages, same content, same server, same network. One shows text 0.6 seconds after the click. The other shows a spinner for 2.4 seconds and then the text.
The difference is where the HTML was generated. That is the whole subject of this page, and it turns out to be one of the few frontend decisions that is hard to change later, because it shapes how data is fetched, where secrets can live, and what the bundle contains.
1. The four places HTML can come from
Client-side rendering
The server sends an almost empty HTML file with a <div id="root"> and a script tag. Everything else happens in the browser.
The user's wait is a chain, and each link must finish before the next starts: download HTML → download JavaScript → parse and execute it → fetch the data → render. Four sequential round trips minimum, and the two middle ones scale with your bundle size on the user's device, not on yours.
Where it is genuinely right: applications behind a login where search engines are irrelevant and the user will stay for a long session — a dashboard, an admin console, an internal tool. The first load is slow and every navigation afterwards is instant, which is the correct trade when a session is thirty minutes.
Where it is wrong: anything a search engine or a link preview must read, anything reached from a search result, and anything a user opens once and leaves.
Server-side rendering
The server runs the components, produces HTML, and sends it. The browser paints real content on the first response.
The costs are real and specific. Time to first byte goes up, because the server now waits for data before it can send anything. Every request costs CPU on your server, so this is the strategy that turns traffic into an infrastructure bill. And the page is visible before it is interactive — clicks do nothing until hydration finishes.
Static site generation
Render at build time, upload the HTML to a CDN. There is no server work per request at all, so the response comes from an edge location a few milliseconds away.
Two limits. Content is as old as the last build, so anything changing hourly needs a rebuild pipeline. And build time scales with page count — a hundred thousand product pages is a build measured in hours, which becomes the bottleneck on shipping anything.
Incremental static regeneration
The fix for both static limits, and the same idea as HTTP's stale-while-revalidate (Chapter 5.6.2) applied to whole pages.
A page is generated once and cached. It is served from the cache instantly. After a chosen interval it is considered stale, and the next request still gets the stale copy immediately while the server regenerates in the background. The request after that gets the fresh one.
Pages can also be generated on first request rather than at build time, which removes the hundred-thousand-page build problem: build the top thousand, generate the rest on demand, cache them all.
And on-demand revalidation closes the staleness gap properly. When your content system publishes an edit, it calls a webhook that invalidates exactly that page. You get static performance with near-immediate updates, and the periodic interval becomes a safety net rather than the mechanism.
The honest caveat: the first user after expiry sees stale content, and there is no way around that without blocking them. For a price or stock level, either use a short interval, or render that specific number on the client, or accept it and show a timestamp.
Streaming server rendering
Standard server rendering waits for all the data before sending anything. Streaming uses chunked transfer encoding (Chapter 5.6.1) to send HTML in pieces as it becomes ready.
tsx
export default function ProductPage({ id }: { id: string }) {
return (
<Layout>
<ProductHeader id={id} /> {/* (1) fast — sent immediately */}
<Suspense fallback={<ReviewsSkeleton />}> {/* (2) a flush boundary */}
<Reviews id={id} /> {/* slow — arrives later */}
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations id={id} /> {/* slowest */}
</Suspense>
</Layout>
);
}Line (1) renders from data that is already available, so the shell and the header go out at once. Line (2) marks a boundary: the server sends the skeleton now, keeps the connection open, and when Reviews resolves it streams the real markup plus a tiny inline script that swaps it in.
What this buys is that time to first byte stops depending on your slowest query. A recommendations service that takes 900 ms no longer delays the product title. It is the single biggest improvement to server rendering in years, and it requires only that you place Suspense boundaries around slow regions.
Choosing
| Content | Strategy | Why |
|---|---|---|
| Marketing, docs, blog | Static | Never personal, rarely changes |
| Product catalogue | Static or incremental | Public, high traffic, changes daily |
| Search results, feeds | Server, streaming | Different per request |
| Dashboard behind login | Client | No search engine, long session |
| Checkout | Server | Personal, must be correct, must be fast |
These are per-route decisions, not per-application. Every modern framework lets one route be static and the next server-rendered, and treating the choice as global is what leads to server-rendering an admin panel nobody can find on Google.
2. Hydration, and why it costs what it does
The HTML arrived and the user can see it. It is not interactive, because HTML has no event handlers and React has no idea which component produced which node.
Hydration is the process of fixing that. React runs every component again on the client, builds the fiber tree from Chapter 6.4.1, and — instead of creating DOM nodes — walks the existing ones and attaches its root event listener and internal state.
The consequence people underestimate: hydration costs roughly the same CPU as rendering from scratch. The DOM work is skipped, but every component function still runs, every hook still initialises, every piece of state is rebuilt. So server rendering makes content appear sooner and does not make the page interactive sooner. On a slow phone it can be later, because the phone now has to parse the JavaScript and re-run all the components.
This produces the gap the diagram showed: the page looks finished and does nothing. A user taps a button, nothing happens, they tap again — and when hydration completes, both taps fire. It is measured directly by Interaction to Next Paint in Chapter 6.7, and it is why "we added SSR and the scores got worse" is a real thing that happens.
Mismatches, and the six causes
React requires the first client render to produce exactly the tree the server sent. When it does not, you get a hydration error, and in the worst case React discards the server HTML and re-renders everything on the client — throwing away the entire benefit.
The causes, in the order you will meet them:
Anything time-based. new Date(), Date.now(), a relative "3 minutes ago" — the server rendered at one instant and the client hydrates at another.
Anything random. Math.random(), an id from a counter. Use useId (Chapter 6.4.2).
Locale-dependent formatting. toLocaleString() uses the server's locale and timezone, then the browser's. A price or date formatted this way mismatches for most of your users. Format with an explicit locale and timezone, or format on the client only.
Browser-only values. window.innerWidth, matchMedia, localStorage — none exist on the server. This is the persisted-state problem from Chapter 6.4.3, with the same two fixes.
Invalid HTML nesting. This one is sneaky. If you render a <div> inside a <p>, the server's string contains that nesting, but the browser's parser rewrites it (Chapter 6.2.1) before React ever sees it. React then compares its expected tree against a tree the parser silently reorganised, and reports a mismatch that looks nothing like the actual cause. A hydration error with no obvious dynamic value is very often invalid nesting.
Browser extensions that inject markup into the page. Nothing you can do about those beyond not panicking when a user reports one.
The correct fix is almost always to make the first client render match, not to suppress the warning:
tsx
// The value that differs is rendered only after the first client render.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return <span>{mounted ? formatRelative(date) : formatAbsolute(date)}</span>;suppressHydrationWarning exists and is correct in exactly one situation: a single text node you know will differ, such as a timestamp, where you accept the client value. It suppresses the message, not the mismatch.
3. Sending less JavaScript for the same page
Hydration's cost is proportional to how much of the page is interactive. Three approaches attack that from different angles, and it is worth knowing what each actually changes.
Selective and progressive hydration. React hydrates Suspense boundaries independently and prioritises the one the user interacts with first. If someone clicks a component that has not hydrated yet, React hydrates that boundary immediately and replays the click. It reduces the perceived gap without reducing total work.
Islands. Ship static HTML for the whole page and hydrate only the specific interactive regions — a search box, a basket button — as separate small applications. A blog with one comment form ships the JavaScript for the comment form and nothing else. The trade is that islands cannot easily share client state, because they are separate roots, which makes it a poor fit for a highly interactive application and an excellent one for content sites.
Resumability. Serialise the application's state and event-handler locations into the HTML, and attach a handler only when the user actually interacts. There is no hydration step at all. This is what Qwik demonstrates. It is a genuinely different model rather than an optimisation of this one, and the trade is complexity plus a smaller ecosystem.
React Server Components
The React answer, and it is a different mechanism from all three.
A Server Component runs only on the server. Its code is never sent to the browser. It can read the database directly, use secrets, and await data inline. What it sends to the client is not HTML and not JavaScript but a serialised description of the rendered output.
tsx
// app/orders/page.tsx — a Server Component by default in this model.
export default async function OrdersPage() {
const orders = await db.orders.findMany({ take: 50 }); // (1) runs on the server only
return (
<section>
<h1>Orders</h1>
<OrderTable rows={orders} /> {/* (2) also a Server Component */}
<ExportButton orderIds={orders.map(o => o.id)} /> {/* (3) a Client Component */}
</section>
);
}tsx
// components/ExportButton.tsx
'use client'; // (4) the boundary marker
export function ExportButton({ orderIds }: { orderIds: string[] }) {
const [busy, setBusy] = useState(false); // hooks need the client
…
}Line (1) queries the database inside a component. There is no API route, no loading state, no useEffect — and the database client is never in the bundle, because this function does not ship.
Line (4) marks the boundary. 'use client' means "this file and everything it imports goes to the browser". Everything above the boundary stays on the server.
Three rules that follow from the boundary, and they are the ones that trip people:
- Props crossing the boundary must be serialisable. Objects, arrays, strings, numbers, dates — yes. Functions and class instances — no. (Server actions are the deliberate exception, and they are function references the framework can route back to the server.)
- Server Components cannot use state, effects, or browser APIs. They render once, on the server. No
useState, noonClick. - A Client Component can render a Server Component only as
children, not by importing it — because the import would pull server code into the client bundle.
What this actually buys: the interactive parts of the page ship JavaScript and the rest does not. A page that is mostly display becomes almost entirely free, and the data fetching moves next to the data, removing a whole tier of API endpoints that existed only to feed your own frontend.
The honest costs: the mental model is genuinely new, the server/client boundary is a constant source of confusion in the first months, and the ecosystem of client-only libraries needs 'use client' wrappers. It is a significant bet, not a free upgrade.
4. Where caching sits
Rendering strategy and caching interact, and the layers are worth naming once, because a page can be cached in four places at the same time (Chapter 5.6.2 covers the HTTP mechanics):
The CDN holds the rendered HTML. This is what makes static and incremental rendering fast, and it is where a Cache-Control header with a long max-age plus revalidation belongs.
The framework's data cache holds the results of fetches performed during rendering, so two components requesting the same thing produce one request.
The client router cache holds pages the user has already visited, making Back instant.
The browser's HTTP cache holds everything else.
Personalisation is what breaks the top layer, and the standard answer is worth stating: cache the shared shell at the CDN and fill the personal parts separately — a streamed Suspense boundary, or a small client-side fetch. Rendering the whole page per user because the header shows a name gives up the CDN for one line of text.
What the interviewer will push on
"CSR, SSR, SSG, ISR — when each?" Per route, not per application. Static for public and rarely changing, incremental when it changes but not per user, server for per-request content, client for authenticated long-session tools. The tell is treating it as a routing-level decision.
"Does server rendering make the page faster?" It makes content appear sooner. It does not make the page interactive sooner, and on a slow device it can be later, because hydration re-runs every component on top of parsing the bundle. Anyone who says "SSR is faster" without that distinction has not measured it.
"What is hydration and why is it expensive?" Re-running the component tree on the client to attach handlers and rebuild state. It skips DOM creation and nothing else, so it costs about as much CPU as a fresh render.
"Name the causes of a hydration mismatch." Time, randomness, locale formatting, browser-only APIs, invalid HTML nesting, extensions. Volunteer the nesting one — the browser's parser rewrites bad markup before React sees it, so the error points nowhere near the cause.
"What problem does streaming solve?" Time to first byte stops depending on the slowest query. Suspense boundaries are flush points, and the server keeps the connection open and swaps content in as it resolves.
"What can and cannot cross the 'use client' boundary?" Serialisable props only — no functions, no class instances. Server Components have no state, no effects and no browser APIs, and a Client Component can only receive one as children. The payoff is that server-only code never enters the bundle.
"How would you make an incrementally regenerated page update immediately when an editor publishes?" On-demand revalidation from a webhook, with the time interval kept as a safety net. Then be honest that with interval-based revalidation alone, one user always sees the stale version.
One thing to volunteer: describe the gap where a server-rendered page is visible but not interactive — the user taps twice and both taps fire when hydration lands. It names the actual user-facing cost of the strategy, connects to Interaction to Next Paint, and shows you have looked at real sessions rather than a lighthouse score.
Recall
- Client rendering = HTML → JS → parse → fetch → render, four sequential waits; right for authenticated long-session tools only. Server rendering = content early, higher time to first byte, CPU per request. Static = CDN-fast, but stale until rebuild and build time scales with page count. Incremental = serve stale instantly, regenerate in the background, generate on first request, and invalidate on demand from a publish webhook.
- Streaming uses chunked transfer with
Suspenseboundaries as flush points, so time to first byte stops depending on your slowest query. - Choose per route, not per application.
- Hydration re-runs every component on the client to attach handlers and rebuild state. It skips DOM creation and nothing else, so it costs about as much CPU as a fresh render — server rendering makes content appear sooner, not interaction.
- The gap between visible and interactive is real: taps do nothing, then all fire at once. Measured by Interaction to Next Paint.
- Mismatch causes: time, randomness, locale formatting, browser-only APIs, invalid HTML nesting, extensions. Invalid nesting is the sneaky one — the parser rewrites the markup before React compares it.
- Fix a mismatch by making the first client render match (render after mount).
suppressHydrationWarninghides the message, not the problem. - Sending less JavaScript: selective hydration (prioritise what is interacted with), islands (hydrate only interactive regions; poor state sharing), resumability (no hydration at all), and React Server Components (code never ships; direct data access;
'use client'boundary; serialisable props only, no state or effects on the server side, and a Client Component can only take one aschildren). - Four cache layers — CDN, framework data cache, router cache, browser cache. Personalisation breaks the CDN layer; cache the shell and stream or fetch the personal parts.
Self-test: Why can adding server rendering make interactivity later on a phone? · What exactly does incremental regeneration serve to the first user after expiry? · Why does invalid HTML nesting cause a hydration error that points nowhere useful? · What can a Server Component not do? · What does a Suspense boundary mean during streaming?
Next: 6.5.2 puts these strategies into the two frameworks you are most likely to meet in an interview — how Next.js's routing and caching actually behave, and what Angular does differently enough to be worth knowing on its own terms.