CSR, SSR, SSG & Hydration
Where your page's HTML gets built (in the browser, on the server per request, or once at build time) and what that does to what users see first. With React's real renderToString, hydrateRoot and streaming running live in the page.
The 30-second version
You've built a React app with Vite. It works. Then someone asks: "Why is the page blank for two seconds on my phone?", "Why does Slack show no preview when I paste our link?", or "Should we move to Next.js (a React framework that renders on the server) for SSR?" All three are questions about the same thing: where your page's HTML gets built.
- Build timeonce, before anyone visits (npm run build)
- Server, per requestevery time someone opens the page
- Browser HTML built hereon the visitor's device
There's a twist that the analogy needs one more piece for. With SSR and SSG the plate arrives looking perfect, but the buttons don't work yet. HTML can't carry JavaScript functions, so the browser still has to download your code and run it to attach click handlers. That step is called Hydration, and most of the surprises in this topic live there.
HTML first, JavaScript later
Hydration must match
Pick per page
Here are the two entry points you'll see over and over. The only difference is one function name, and it changes everything about what the browser does:
import { createRoot, hydrateRoot } from "react-dom/client";
import { App } from "./App.jsx";
const el = document.getElementById("root");
createRoot(el).render(<App />); // CSR: the div is empty, build every DOM node from scratch
// or
hydrateRoot(el, <App />); // SSR/SSG: the HTML is already there, adopt it and attach handlersThe rest of the topic builds this up one step at a time: what arrives in the browser (§02), CSR (§03), SSR (§04), hydration and its mismatches (§05–06), building at build time with SSG and revalidation (§07), streaming (§08), Server Components (§09), islands (§10), how to choose (§11), and what goes wrong in production (§12–13). It assumes you know what a render is; if not, skim How React Works → Render, then commit first.
HTML first, JavaScript later
What happens when you press Enter
Here's a page load, slowed down. The browser does these in order, and it can only start painting once it has some HTML:
- The browser asks your server (or a CDN) for the page and gets back an HTML document.
- It reads the HTML, builds the DOM (the browser's tree of page objects) and paints it. If the HTML contains text, the user sees text. If it contains an empty
<div>, they see nothing. (The full pipeline is in Browser Rendering → parsing.) - Meanwhile it downloads the
<script>files the HTML points to. Only when one has downloaded and run can it change the page or respond to clicks.
Three moments worth naming
Every strategy in this topic moves these three moments around, so they get names:
| Moment | Name | What the user experiences |
|---|---|---|
| The HTML starts arriving | Time to first byte (TTFB) | Still a blank tab, but the browser can start working |
| First real text or image is drawn | FCP (and LCP for the biggest element) | "It loaded" |
| Clicks and typing actually work | Interactive | "It works" |
The trap: "it loaded" and "it works" are different moments, and different strategies put the gap in different places. LCP is one of Google's Core Web Vitals, which the Web Performance topic covers in depth.
See it: the same page, two ways
Toggle between a client-rendered and a server-rendered version of the same tiny menu page. The left side is the exact HTTP response. The right side is what the browser can draw from that response before any JavaScript runs:
<!doctype html>
<html>
<head><title>Menu</title></head>
<body>
<div id="root"></div>
<script type="module" src="/assets/app.js"></script>
</body>
</html>Two things to notice:
- CSR's response is almost empty. All the content is built later by
app.js. Before then the page is white. - SSR's button is drawn but dead. It's real HTML, so it shows up immediately. But the
onClickhandler is JavaScript, and none has run yet. We'll come back to this gap in §05.
Who else reads that first HTML
Not every visitor is a person with a fast laptop:
- Search engines and link previews. A crawler downloads your page to index it, or to build the card you see when you paste a link into Slack. Google does run JavaScript, but in a second, delayed pass. Many link-preview bots don't run it at all, so a CSR page shows up as a blank card.
- Slow phones. Downloading and running a 300 kB JavaScript bundle (all your app's code plus React, packed into one file or a few) can take a few seconds on a cheap phone over mobile data. With CSR, that whole time is a blank screen.
- When JavaScript fails. A bundle that 404s after a deploy, a blocked script, a JavaScript error at startup: with CSR the page stays blank; with server-built HTML the content is still readable.
None of this means CSR is wrong. It means the first HTML matters, and §03 onward shows what each strategy puts in it.
Client-side rendering (CSR)
This is what npm create vite@latest gives you. The whole HTML file looks like this, for every URL of your app:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Menu</title>
</head>
<body>
<div id="root"></div> <!-- ← empty: nothing to paint yet -->
<script type="module" src="/src/main.jsx"></script> <!-- ← everything happens in here -->
</body>
</html>And the JavaScript that fills it:
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
function App() {
const [dishes, setDishes] = useState(null);
useEffect(() => {
fetch("/api/dishes") // ← data request starts only AFTER the first render
.then((r) => r.json())
.then(setDishes);
}, []);
if (!dishes) return <p>Loading…</p>;
return <ul>{dishes.map((d) => <li key={d.id}>{d.name}</li>)}</ul>;
}
createRoot(document.getElementById("root")).render(<App />); // ← build the DOM from nothingThe waterfall
Look at the order of events that has to happen before the user sees a single dish. Each step can only start when the one before it has finished:
- Download
index.html(tiny, fast). - Download the JavaScript bundle (React plus your app: often 100–500 kB).
- Parse and run it. React renders
Loading…. - The effect fires and starts
fetch("/api/dishes"). Another round trip to the server, which queries a Database. - The data arrives. React renders the list. Now the user sees content.
A chain of requests where each one waits for the previous is called a waterfall. CSR has one built in: the data request can't start until the code that makes it has arrived and run.
Measured: CSR vs SSR vs SSG
We built the same 40-item React page three ways and loaded each one in headless Chromium with a throttled network and CPU, to imitate a mid-range phone on mobile data. Press play, or drag the slider, to watch the three loads side by side:
| Strategy | HTML arrives | First content | Clickable |
|---|---|---|---|
| CSR | 153 ms | ≈ 1,270 ms | ≈ 1,270 ms |
| SSR | 454 ms | ≈ 536 ms | ≈ 1,060 ms |
| SSG | 153 ms | ≈ 228 ms | ≈ 769 ms |
Read the CSR row carefully. Its HTML arrived fastest (tied with SSG), because the server didn't have to do anything. But the user saw nothing for about 1.3 seconds: the bundle download, the startup, then the API round trip, all in a row. On the other hand, once it painted, it was already interactive. There's no "looks ready but isn't" phase, because the same JavaScript that drew the page attached the handlers.
What CSR is genuinely good at
window, localStorage and the screen size are always available.Where it hurts
- First load on slow devices. Everything the user sees waits on the biggest download and the slowest CPU. Code splitting helps: cutting the bundle into several files so the first page only downloads what it needs (Web Performance covers it), but the waterfall stays.
- Public pages that need to be found or shared. Product pages, articles and landing pages lose the link previews and the reliable search indexing described in §02.
- Blank on failure. If the bundle fails to load, there is no page.
The fix for all three is the same idea: send real HTML in the first response. That means building it before the browser asks: either per request (§04) or ahead of time (§07).
Server-side rendering (SSR)
The same components, on a server
React can run anywhere JavaScript runs, including a Node.js server. The package react-dom/server has a function, renderToString, that calls your components once and gives back HTML instead of touching a DOM. Try it: this widget runs the real function, in this page, every time you change the "request":
function Greeting({ user, items }) {
return (
<section className="card">
<h2>Hello, {user || "guest"}</h2>
{items.length ? (
<ul>{items.map((it) => <li key={it}>{it}</li>)}</ul>
) : (
<p>Your basket is empty.</p>
)}
<button onClick={() => alert("hi")}>Checkout</button>
</section>
);
}
// On the server, for this request:
const html = renderToString(<Greeting user={user} items={items} />);onClick handler. HTML can't carry a JavaScript function, so the button arrives dead. The <!-- --> comment after “Hello,” marks where two separate text pieces meet, so the browser-side React can find them again later (§05).A whole SSR server is that call, plus a little plumbing. Here's one with no framework, using only Node's built-in http module:
import http from "node:http";
import { renderToString } from "react-dom/server";
import { App } from "./App.jsx";
import { db } from "./db.js";
// Turn data into JSON that is safe to put inside a <script> tag (see §12 for why)
const safeJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
http.createServer(async (req, res) => {
try {
const dishes = await db.getDishes(); // ← 1. fetch data on the server
const html = renderToString(<App dishes={dishes} />); // ← 2. run the components → HTML
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(`<!doctype html>
<html><body>
<div id="root">${html}</div>
<script>window.__DATA__ = ${safeJson(dishes)}</script>
<script type="module" src="/assets/client.js"></script>
</body></html>`); // ← 3. send a full page
} catch (err) {
console.error(err); // database down, render crashed…
res.statusCode = 500;
res.end("Sorry, the menu could not be loaded."); // always answer the request
}
}).listen(3000);Why put the data in the page as well as in the HTML? Because the browser is about to run the same App component to make the page interactive (§05), and it needs the same dishes to produce the same output. Sending them along saves a second fetch and guarantees both sides start from identical data.
SSR isn't a React invention
Servers built HTML per request for decades before React, with a Template engine. Here's the same page in Go with the standard library's html/template. If you've used PHP, Rails, Django or Go templates, you've done SSR:
// Parsed once at startup. {{range .}} loops over the data; {{.Name}} is HTML-escaped automatically.
var menuPage = template.Must(template.New("menu").Parse(`<!doctype html>
<title>Menu</title>
<h1>Today's menu</h1>
<ul>{{range .}}<li>{{.Name}} — €{{.Price}}</li>{{end}}</ul>`))
func menu(w http.ResponseWriter, r *http.Request) {
dishes, err := loadDishes(r.Context()) // ← fresh data for THIS request
if err != nil {
http.Error(w, "could not load the menu", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := menuPage.Execute(w, dishes); err != nil { // ← HTML built here, per request
log.Println("render:", err)
}
}What React adds is that the same component code produces the HTML on the server and takes over in the browser afterwards. With a Go template, any interactivity has to be written separately in JavaScript.
What you pay for it
Go back to the measurements in §03. SSR's first content came at about 536 ms instead of 1,270 ms. But look at when its HTML arrived: 454 ms, against 153 ms for CSR. The server held the response while it waited for the 300 ms database query and rendered. Three costs follow from that:
- Later first byte. The browser can't do anything until the server finishes. A slow query on the server means a blank tab with a spinning icon. (Streaming fixes much of this, §08.)
- A server, working on every request. CSR and SSG can be plain files on a CDN. SSR needs a running process, and 1,000 visitors means 1,000 renders. Rendering is CPU work: we measured about 2.5 ms for a simple 1,000-row table on this machine, and real pages with many components and data shaping often cost more (§12).
- Code that runs in two places. The same component now runs on the server, where
window,documentandlocalStoragedon't exist, and in the browser. Code that touches them during render crashes on the server, or worse, produces different output (§06).
And there's one gap SSR adds that CSR didn't have. Look at the SSR row in the timeline again: content at 536 ms, clickable at 1,060 ms. For half a second the page looked ready while the buttons did nothing. That gap is called the Uncanny valley. What happens at the end of it is hydration, and it's the next section.
Hydration: making server HTML come alive
The browser side of an SSR app is a few lines. It uses hydrateRoot instead of createRoot, and it renders with the data the server put in the page (§04):
import { hydrateRoot } from "react-dom/client";
import { App } from "./App.jsx";
hydrateRoot(
document.getElementById("root"),
<App dishes={window.__DATA__} /> // ← same component, same data as the server used
);Step through it
1// client.jsx — the browser entry point2import { hydrateRoot } from "react-dom/client";3import { App } from "./App.jsx";45hydrateRoot(document.getElementById("root"), <App />);67// App.jsx — the SAME component the server rendered8export function App() {9 const [n, setN] = useState(0);10 return <button onClick={() => setN(n + 1)}>Clicked {n} times</button>;11}
The key move is step 5. React doesn't diff the server's HTML as one big string. It renders your components into elements as usual, then walks the existing DOM tree alongside them, node by node. At each node it checks that the tag matches (a button where it expects a button) and that text nodes hold the text it just rendered, and records "this element is that <button>". If a check fails, that's a hydration mismatch (§06). That's why the result has to fit: React assumes the DOM it finds is the DOM it would have built.
Try it for real
This lab runs both halves in your browser. Step 1 calls React's real renderToString and puts the HTML into the white box, exactly as a server response would. Step 2 calls the real hydrateRoot on it. Keep the case on Matching for now, and click the button between steps 1 and 2:
<p>Hello from the same markup on both sides</p>What the lab shows:
- Before hydration, clicks do nothing. The HTML button is real and visible, but no handler is attached. Those clicks are simply lost: React wasn't running when they happened, so it can't replay them.
- After hydration, React kept the same DOM node. The widget checks whether the
<button>element that was there before step 2 is still in the page afterwards. It is: hydration attached to it rather than replacing it. That's what makes hydration invisible to the user: no flicker, and a half-typed input keeps its text.
What hydration costs
It's easy to think "the server already rendered it, so the browser has less to do". Not really. During hydration the browser still:
- downloads the whole JavaScript bundle, the same size as with CSR;
- runs every component on the page once, to know which handler belongs to which node;
- just skips creating the DOM nodes, which is a fairly small part of the work.
So SSR moves when the user first sees content, not how much JavaScript the browser runs. On a slow phone the Uncanny valley can last seconds: the page looks finished, the user taps "Add to cart", and nothing happens. §08 (streaming), §09 (Server Components) and §10 (islands) are three different attacks on exactly this cost.
Hydration mismatches: when server and browser disagree
See one happen
Same lab as §05, now starting on a mismatch. In Mismatch: time, the component prints the current time with milliseconds. Step 1 renders it on the "server"; when you press step 2 a moment later, the browser's render prints a later time. Mismatch: window imitates the classic typeof window check. Fixed: useEffect shows the repair.
<p>Rendered at {new Date().toLocaleTimeString()}</p> // server time ≠ browser timeWhen the text doesn't match, React 19 reports this in a development build (the lab captures the real message):
Hydration failed because the server rendered text didn't match the client.In a production build, React ships without its long error texts to save bytes, so the same error arrives as just "Minified React error #418" with a link to react.dev that explains it. If you're reading this page on the production site, the lab shows that code and the readable text next to it.
Then it does something drastic: it discards the server's DOM for that part of the page and renders it again from scratch in the browser. The lab checks the <button> node before and after: on a mismatch, the old node is gone. Everything the server did for that part was wasted, the user may see the content flicker, and any text they had typed into an input there is lost.
What React does, by kind of difference
| What differs | What React does | Visible symptom |
|---|---|---|
| Text, or the element structure | Reports a hydration mismatch; throws away the server DOM up to the nearest Suspense boundary (or the whole root) and re-renders in the browser | Flicker, lost input, slower page |
An attribute (className, style, href…) | In development, logs "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up." | The server's value stays. The page keeps the wrong class or link until something re-renders it |
That second row surprises people. We checked it with React 19.3: hydrating <p class="server"> with a component that renders className="client" leaves class="server" in the DOM, silently in production.
The usual suspects
| Cause | Example | Fix |
|---|---|---|
| Time | new Date().toLocaleTimeString(), "posted 3 minutes ago" | Pass the time from the server, or fill it in after hydration (below) |
| Time zone and locale | date.getHours(), n.toLocaleString(): the server runs in UTC with en-US, the user doesn't | Format with an explicit zone and locale, e.g. Intl.DateTimeFormat("de-DE", { timeZone }), decided on the server and passed down |
| Randomness | Math.random(), crypto.randomUUID() for an id | useId() for ids; generate random values once on the server and pass them |
| Browser-only values | typeof window !== "undefined", window.innerWidth, localStorage.getItem("theme") | Render the same thing on both sides first, then update after hydration; or use CSS media queries for layout |
| Invalid HTML nesting | <p><div>…</div></p>, <a> inside <a> | Fix the markup. The browser's HTML parser "repairs" it into a different tree |
| Data that changed | The browser refetches instead of using the server's data | Hydrate with the data the server used (window.__DATA__, §04) |
| Browser extensions | Translators, password managers, Grammarly adding attributes or nodes | Mostly harmless (often attributes); nothing to fix in your code |
The nesting one is sneaky, because your code renders the same thing on both sides. The problem is the browser. We fed <p><div>block</div></p> to the browser's parser and got back <p></p><div>block</div><p></p>: a <div> isn't allowed inside a <p>, so the parser closes the paragraph early. Now the DOM has three elements where React expects one. React warns about invalid nesting in development, so read those warnings.
The standard fix: render twice
When something can only be known in the browser, make the first browser render match the server, then update. An effect runs only in the browser and only after hydration, so it's the natural place:
import { useEffect, useState } from "react";
export function LocalTime() {
const [time, setTime] = useState(null); // ← server AND first browser render: null
useEffect(() => {
setTime(new Date().toLocaleTimeString()); // ← browser only, after hydration
}, []);
return <p>Rendered at {time ?? "…"}</p>; // both sides first render "…"
}That's the Fixed: useEffect case in the lab: no error, React keeps the server's DOM, and the time pops in right after hydration as an ordinary update. The cost is a brief placeholder. Use it for small things (a timestamp, a "Hi, Ada" in the header), not for the main content, or you're back to CSR for that part.
Two smaller tools
useId()gives an id that's the same on the server and in the browser, because React derives it from the component's position in the tree. Use it to connect a<label htmlFor>to an<input id>instead of a random id.suppressHydrationWarningon an element tells React "this element's own text may differ; keep the server's". We checked: React 19.3 left the server text in place and reported nothing. It only applies one level deep (that element's text and attributes, not its children), and the user keeps seeing the server's value, so it's for timestamps that are "close enough", not a general off switch.
The theme flash
A classic: the user's dark/light choice is stored in localStorage. The server can't read localStorage, so it renders the light theme; the browser then switches to dark, and the page flashes white on every load. The fix is to put the choice somewhere the server can read: a cookie is sent with every request. Or run a tiny inline <script> in the <head> that sets a class before the first paint. (Browser Storage compares where to keep things like this.)
Build it once: SSG and revalidation
SSG is SSR, run ahead of time
It's the same react-dom/server idea from §04, moved from request time to Build time. React 19 has a function made for it, prerender from react-dom/static: unlike renderToString, it waits for all data to load before it returns, which is what you want when nobody is waiting.
import { mkdir, writeFile } from "node:fs/promises";
import { prerender } from "react-dom/static";
import { App } from "./App.jsx";
import { db } from "./db.js";
const safeJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c"); // same as §04
for (const dish of await db.getAllDishes()) {
const { prelude } = await prerender(<App dish={dish} />); // ← waits for ALL data, then renders
const body = await new Response(prelude).text(); // stream → string
const html = `<!doctype html>
<html>
<head><meta charset="utf-8" /><title>Menu</title></head>
<body>
<div id="root">${body}</div>
<script>window.__DATA__ = ${safeJson(dish)}</script>
<script type="module" src="/assets/client.js"></script>
</body>
</html>`; // ← the same page shape as §04
await mkdir(`dist/dish/${dish.slug}`, { recursive: true });
await writeFile(`dist/dish/${dish.slug}/index.html`, html); // ← one file per page
}The data has to travel with the page, exactly as in §04: the browser will run App again during hydration (§05), and it needs the same dish to produce the same HTML. (Here App is a one-dish page, so its client entry would call hydrateRoot(root, <App dish={window.__DATA__} />) rather than passing the dishes list from §05.) SSG pages hydrate exactly like SSR pages; the only difference is when the HTML was made.
The output is a folder of plain files. You upload it to a CDN, which keeps copies in cities around the world, so a visitor in Tokyo gets the file from Tokyo. That's why SSG won the measurements in §03: first content at about 228 ms, with nothing to compute.
Telling caches how long a page is good for
A CDN decides how long it may reuse a copy from the Cache-Control header. If your pages come from your own server (say, a Go service in front of the dist folder), you set it there:
func dishPage(w http.ResponseWriter, r *http.Request) {
// Browsers: always check again (max-age=0).
// CDN: reuse your copy for 60 s, then serve it stale for up to 5 min while you fetch a new one.
w.Header().Set("Cache-Control", "public, max-age=0, s-maxage=60, stale-while-revalidate=300")
http.ServeFile(w, r, filepath.Join("dist", "dish", path.Base(r.PathValue("slug")), "index.html"))
}
// Registered with: http.HandleFunc("GET /dish/{slug}", dishPage) (Go 1.22+ route patterns)Where SSG runs out
Do the arithmetic for a shop with 20,000 products. If each page takes 50 ms to fetch its data and render (an assumed, round number), a full build spends 20,000 × 50 ms = 1,000 s, about 17 minutes, just rendering. And then:
- A price change needs a rebuild. Until then, every visitor sees the old price.
- Nothing per user. The file is the same for everyone, so "Hi, Ada" or the cart count has to be filled in by JavaScript in the browser after load.
- Pages that don't exist yet. A product added at 10:05 has no file until the next build.
Incremental regeneration: a cache that refreshes itself
The fix most frameworks offer is to keep the pre-built pages but let each one expire. When a visitor asks for a page that's older than, say, 10 seconds, the server hands over the old copy immediately and rebuilds the page in the background; the next visitor gets the new one. Next.js calls this incremental static regeneration (ISR). Underneath, it's stale-while-revalidate applied to whole pages.
Play with it. Time only moves when you press a Wait button, so you can follow each step:
- t=0s Build: page generated with price €9 and put on the CDN.
Things to try, and what they teach:
- Edit the price, then send a visitor. They get the old price, and nothing else happens: the page is under 10 s old, so it still counts as fresh. Editing the database doesn't touch the cache.
- Wait until the page is older than 10 s, then send another visitor. They also get the old price instantly, but their visit triggers the rebuild. Someone had to see the stale version to set it off.
- Send a visitor while the rebuild is running. Still the old price. Only after the rebuild finishes does the cache change.
- Use on-demand revalidation. Instead of waiting for the timer, your CMS calls a Webhook when someone hits Save, and the page is marked stale right away. That's On-demand revalidation. It shrinks the window of wrong prices without rebuilding pages nobody changed.
So ISR trades a little freshness for SSG speed: you choose how stale is acceptable per page. A blog post can be an hour old; a stock level probably can't.
Streaming SSR: send what's ready, now
The problem, in numbers
A product page needs three things: the product (a 50 ms query), its reviews (1.2 s, a slow service) and recommendations (2.4 s, a slower one). With renderToString (§04), the server must have all the data before it can render, so:
- the user stares at a blank tab for 2.4 s, even though the product name and price were ready after 50 ms;
- the slowest, least important part of the page decides how fast the most important part shows up.
Marking the slow parts: Suspense
You tell React which parts may arrive late by wrapping them in a Suspense boundary. Inside, a component reads its data with React 19's use():
import { Suspense, use } from "react";
function Reviews({ reviewsPromise }) {
const reviews = use(reviewsPromise); // ← not ready yet? React shows the fallback instead
return <ul>{reviews.map((r) => <li key={r.id}>{r.text}</li>)}</ul>;
}
export function ProductPage({ product, reviewsPromise }) {
return (
<main>
<h1>{product.name}</h1> {/* ready now */}
<Suspense fallback={<p>Loading reviews…</p>}> {/* may arrive later */}
<Reviews reviewsPromise={reviewsPromise} />
</Suspense>
</main>
);
}The server side
On Node you use renderToPipeableStream instead of renderToString. It calls you back when the Shell (everything outside pending boundaries, plus their fallbacks) is ready:
app.get("/product/:id", async (req, res) => {
const product = await db.getProduct(req.params.id); // fast: wait for it
if (!product) return res.status(404).send("No such dish"); // ← decide the status BEFORE streaming
const reviewsPromise = db.getReviews(product.id); // slow: DON'T await, pass the promise
const { pipe, abort } = renderToPipeableStream(
<ProductPage product={product} reviewsPromise={reviewsPromise} />,
{
bootstrapModules: ["/assets/client.js"],
onShellReady() { // ← shell rendered: start sending
res.setHeader("Content-Type", "text/html; charset=utf-8");
pipe(res);
},
onShellError() { // ← the shell itself failed
res.status(500).send("<h1>Something went wrong</h1>");
},
onError(err) {
console.error(err); // log errors inside boundaries
},
}
);
setTimeout(abort, 10_000); // give up on stragglers after 10 s
});Watch a real stream
The lab runs renderToReadableStream in this page, with two fake queries (1.2 s and 2.4 s). Every chunk React produces is logged with its arrival time, and written straight into a little browser frame, the way a real tab receives it. Run it in Stream mode, then in Wait for all data mode, and compare when the heading appears:
function Page() {
return (
<main>
<h1>Pizza Margherita</h1>
<p>€9 · ready in 15 min</p>
<Suspense fallback={<p>Loading reviews…</p>}>
<Reviews /> {/* 1.2 s query */}
</Suspense>
<Suspense fallback={<p>Loading recommendations…</p>}>
<Recs /> {/* 2.4 s query */}
</Suspense>
</main>
);
}What's inside the chunks
Open the chunks in the lab. Here's what React actually sent (taken from its output, scripts shortened):
<main><h1>Pizza Margherita</h1><p>€9 · ready in 15 min</p>
<!--$?--><template id="B:0"></template><p>Loading reviews…</p><!--/$-->
<!--$?--><template id="B:1"></template><p>Loading recommendations…</p><!--/$-->
</main><div hidden id="S:0"><ul><li>“Crispy crust, perfect.” ★★★★★</li>…</ul></div>
<script>/* $RC = a ~1 kB helper, sent once */ $RC("B:0", "S:0")</script>- In the shell, each pending boundary is marked with comments (
<!--$?-->means "still loading") and an empty<template id="B:0">as a bookmark, with the fallback inside. - When the data arrives, React renders that boundary and sends its HTML in a hidden
<div id="S:0">, followed by a tiny script. - The script,
$RC("B:0", "S:0"), moves the hidden content into the bookmark's place and removes the fallback. The user sees the reviews pop in.
All of this happens in one HTTP response that simply stays open until the last boundary is done. Because sections can be finished in any order, this is sometimes called out-of-order streaming.
Hydration gets smarter too: selective hydration
Each Suspense boundary also becomes a separate unit of hydration. React can hydrate the shell while the reviews are still streaming, and hydrate the reviews once their HTML and code are there. If the user clicks inside a part that isn't hydrated yet, React hydrates that part first and then replays the click. This is Selective hydration, and it shortens the Uncanny valley from §05 for the parts users actually touch.
The trade-offs
Headers go out with the shell
The HTTP status code and headers are sent with the first chunk. Once the shell has been sent, you can't change your mind: no 404 Not Found, no redirect, no Set-Cookie. So anything that decides those (does the product exist? is the user logged in?) must happen before onShellReady, outside the Suspense boundaries. In the server code above, that's why getProduct is awaited first and the 404 is sent before renderToPipeableStream is even called.
- Errors after the shell can't turn into an error page. If a component inside a boundary throws on the server, React reports it to
onErrorand tells the browser to try rendering that boundary itself. - Crawlers may want everything. A Crawler that doesn't run JavaScript only sees fallbacks for content that's swapped in by script. For bots, wait for
onAllReady(orawait stream.allReady, the Wait for all data mode in the lab) before sending. That's also exactly whatprerenderdoes for SSG (§07). - Something in the middle can buffer it. Some proxies, compression settings and hosting platforms collect the whole response before forwarding it, which silently turns streaming back into "wait for everything". If the lab-style behavior doesn't show up in production, check your proxy's buffering settings.
React Server Components
"use client" are sent to the browser and hydrated. So a page can be mostly server-rendered with zero JavaScript for the parts that never change.Why bother: the hydration bill
§05 ended on a problem: hydration downloads and runs every component, even the ones that will never change after the first render. A blog article with one Like button ships the code for every paragraph, the Markdown parser and the date-formatting library, only so that React can confirm the HTML matches and attach one click handler.
React Server Components split components into two kinds:
| Server Component (the default) | Client Component | |
|---|---|---|
| Marked by | nothing | "use client" at the top of its file (or of a file that imports it) |
| Runs | on the server, once per request or at build time | on the server for the HTML, then in the browser |
| Its code is sent to the browser? | No | Yes |
Can be async and await data, read files, use secrets | Yes | No |
Can use useState, useEffect, onClick | No | Yes |
One request, step by step
1// ── file 1: ProductPage.jsx — a Server Component (the default) ──2import { db } from "./db.js";3import { AddToCart } from "./AddToCart.jsx";45export default async function ProductPage({ id }) {6 const p = await db.products.find(id); // runs on the server7 return (8 <main>9 <h1>{p.name}</h1>10 <AddToCart productId={p.id} />11 </main>12 );13}1415// ── file 2: AddToCart.jsx — a Client Component ──16"use client"; // ← the first line of this file17import { useState } from "react";1819export function AddToCart({ productId }) {20 const [n, setN] = useState(0);21 return <button onClick={() => setN(n + 1)}>Add ({n})</button>;22}
ProductPage. It's an async function: Server Components may await directly, because they never run in the browser.The thing that crosses the network in step 4 is the RSC payload: the finished element tree, with holes like $L1 that mean "client component #1 goes here, with these props". On the first visit the server also turns that tree into HTML (ordinary SSR, §04), so the user sees content immediately. On later navigations only the payload is fetched, and React merges it into the page like a re-render, so client components that stay on screen keep their state.
Where to draw the line
The rule that trips everyone up: "use client" marks a boundary, not a single component. The marked file and everything it imports become client code. Try placing the boundary in this product page and watch the JavaScript total:
What the tree shows:
- Push
"use client"down to the leaves. Marking onlySearchBoxandAddToCartkeeps the Markdown and date libraries on the server. MarkingPage(the "classic SSR" button) ships everything, which is what §04–08 did. - Interactive components must be client. Unmark
AddToCartand the tree reports an error: a Server Component can't haveonClickoruseState, because it never runs in the browser. - Props crossing the boundary must be serializable. A Server Component passes props to a client component through the payload, so they have to be data that can be written as text: strings, numbers, plain objects, arrays, dates, promises. Ordinary functions can't cross (with one exception, below).
Talking back: Server Functions
The mirror image of "use client" is "use server". It marks an async function that runs on the server but can be called from the browser, for example as a form's action:
"use server";
export async function addToCart(formData) {
await db.cart.add(formData.get("productId")); // ← runs on the server
}"use client";
import { addToCart } from "./actions.js";
export function AddToCart({ productId }) {
return (
<form action={addToCart}> {/* ← a server call, no fetch() written by you */}
<input type="hidden" name="productId" value={productId} />
<button>Add to cart</button>
</form>
);
}What it costs
Islands and partial hydration
Why: hydration is all-or-nothing by default
With one hydrateRoot over the whole page (§05), React must render every component in the browser before any of them works, even text that can never change. The Islands architecture idea flips the default: the page is static unless you mark a part as interactive. React Server Components (§09) reach a similar result inside React's own model; islands get there with plain HTML and several small, separate React roots.
See the difference
This article was rendered with React's real renderToString. In Hydrate whole page mode, one hydrateRoot adopts the whole article. In Islands only mode, two separate hydrateRoot calls adopt just the ♥ button (right away) and the comments box (only when you scroll down to it). Compare the render counts, then scroll the article:
- nothing
Show the server HTML (0 chars)
The numbers come from counters inside the components. Hydrating the whole page ran 17 components in the browser: the article, 14 paragraphs, and the two widgets. With islands, the browser ran one on load and one more when the comments came into view. On a real page, the difference is the code for those paragraphs (and any libraries they use) never being downloaded at all.
How a framework spells it
Meta-frameworks built around islands make this a one-word decision per component. In Astro, for example, components render to HTML only, unless you add a client: directive:
---
import LikeButton from "../components/LikeButton.jsx";
import Comments from "../components/Comments.jsx";
---
<article>
<h1>How Neapolitan pizza is made</h1>
<LikeButton client:load /> <!-- ← hydrate as soon as the page loads -->
<p>Neapolitan pizza dough has four ingredients…</p>
<Comments client:visible /> <!-- ← hydrate when it scrolls into view -->
</article>Doing it by hand with React
Under the hood an island is just a separate root. The demo above does essentially this (it skips the dynamic import, since everything is already loaded):
import { hydrateRoot } from "react-dom/client";
import { LikeButton } from "./LikeButton.jsx";
// Island 1: small and at the top, so hydrate it right away
hydrateRoot(document.querySelector('[data-island="like"]'), <LikeButton />);
// Island 2: hydrate only when it scrolls into view
const node = document.querySelector('[data-island="comments"]');
const io = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
io.disconnect(); // ← only once
import("./Comments.jsx").then(({ Comments }) => // ← download its code only now
hydrateRoot(node, <Comments />));
});
io.observe(node);Islands vs Server Components
| Islands | Server Components (§09) | |
|---|---|---|
| Static parts | Plain HTML, no React at all | Rendered by React on the server, merged by React in the browser |
| Interactive parts | Separate roots, each hydrated on its own schedule | Client components inside one React tree |
| Navigation | Usually full page loads (the page is HTML) | In-place updates that keep client state |
| Sharing state between interactive parts | Hard: separate roots don't share context | Normal React context within the client parts |
| Best for | Content sites: docs, blogs, marketing | Apps with lots of content and app-like navigation |
Choosing a strategy (per page)
The decision, interactively
All of it on one table
| CSR | SSR | SSG | SSG + revalidation (ISR) | Streaming SSR | |
|---|---|---|---|---|---|
| HTML built | in the browser | per request | at build | at build, then in the background | per request, in pieces |
| First content (§03) | late (after JS + data) | after the server's slowest query | fastest | fastest | fast parts immediately |
| Per-user content in the HTML | n/a (all client) | yes | no | no | yes |
| Freshness | live (fetched) | live | as of the last build | within the revalidate window | live |
| Needs a running server | no | yes | no | yes (or a platform that does it) | yes |
| Cost per visit | ~0 (a file) | a render | ~0 | ~0, plus occasional rebuilds | a render |
| Crawlers and link previews | poor | good | good | good | good (wait for all data for bots, §08) |
And the two techniques that sit on top of any server-rendered strategy, to cut the hydration bill (§05):
- Server Components (§09): keep non-interactive components on the server, inside React's model.
- Islands (§10): keep the page as plain HTML, hydrate a few widgets independently.
A worked example: one shop, five strategies
| Route | What's special | Strategy |
|---|---|---|
/ and /about | Same for everyone, changes on deploy | SSG |
/blog/:slug | Same for everyone, mostly text, a comment box | SSG + an island (or a client component) for comments |
/product/:id | Same for everyone, but prices and stock change hourly | SSG + revalidation (60 s), on-demand revalidation when an editor saves |
/checkout | Per user, must be correct, slow payment-provider call | SSR, streamed, with the slow part in a Suspense boundary |
/account/* dashboard | Behind a login, very interactive | CSR (or SSR for the first view only) |
Notice that the cart icon appears on every one of these pages, and it's per user. That doesn't make every page SSR: the shared page stays static, and the small personal bit is filled in in the browser after load, or streamed (§08). Keeping personal data out of the shared HTML is what keeps a page cacheable.
Server rendering in production
1. A personal page in a shared cache
The worst SSR incident is also the easiest to cause. A page that says "Hi, Ada" and shows Ada's cart gets a Cache-Control: public, s-maxage=60 header because someone copied it from the product page. The CDN stores Ada's page and serves it, for the next 60 seconds, to everyone who asks for that URL.
// Shared, same-for-everyone HTML: the CDN may keep it
w.Header().Set("Cache-Control", "public, max-age=0, s-maxage=60, stale-while-revalidate=300")
// HTML that depends on who's asking: only the user's own browser, never a shared cache
w.Header().Set("Cache-Control", "private, no-store")The rule: if the HTML reads a Cookie or an auth header, it must not be stored by a shared cache. That's also why §11 keeps personal bits (cart count, avatar) out of the shared HTML and fills them in afterwards: it keeps the expensive part cacheable. Header semantics in depth: HTTP Caching.
2. Request data in a module-level variable
In the browser, a module-level variable belongs to one user: the one holding the tab. On a server, one process handles many users at once, and a module-level variable is shared by all of them.
let currentUser = null; // shared by EVERY request
app.get("/", async (req, res) => {
currentUser = await getUser(req); // request A sets Ada…
const cart = await db.getCart(currentUser.id);
// …while A awaited, request B set Bob
res.send(renderToString(<App cart={cart} />));
});
function Header() {
return <p>Hi {currentUser.name}</p>; // A's page may say "Hi Bob"
}const UserContext = createContext(null);
app.get("/", async (req, res) => {
const user = await getUser(req); // a local: one per request
const cart = await db.getCart(user.id);
res.send(renderToString(
<UserContext value={user}>
<App cart={cart} />
</UserContext>
));
});
function Header() {
const user = use(UserContext);
return <p>Hi {user.name}</p>;
}The bug only shows up under concurrent load, which is why it passes every local test. Any await between setting and reading, and streaming (§08), which interleaves many renders, make it more likely. The same goes for a module-level cache keyed by nothing, or a store library created once at import time instead of once per request. Keep request-scoped data in props, context or a per-request object.
3. XSS through serialized state
§04 put the data into the page with a <script> tag. If you do it with plain JSON.stringify, a product name typed by a user can end your script early:
const dishes = [{ name: "</script><script>alert(document.cookie)</script>" }];
`<script>window.__DATA__ = ${JSON.stringify(dishes)}</script>`
// The browser's HTML parser sees </script> inside the string and closes the tag there.
// Everything after it runs as a new script: XSS.The fix in §04, .replace(/</g, "\\u003c"), turns every < inside the JSON into the six characters \u003c. JavaScript reads \u003c inside a string as the same < character, but the HTML parser only sees a backslash and some letters, never a </script>. We ran both versions in Node 22 with the attack string above:
// plain JSON.stringify: the tag-closing text is still there
[{"name":"</script><script>alert(document.cookie)</script>"}]
// with .replace(/</g, "\\u003c"): no "<" left for the HTML parser to find
[{"name":"\u003c/script>\u003cscript>alert(document.cookie)\u003c/script>"}]Frameworks do this for you; hand-rolled SSR often doesn't. (React itself escapes text it renders into HTML, so the same name inside an <li> comes out as </script>…, which is harmless.) More on this class of bug: OWASP → XSS.
4. Rendering is CPU work on a single thread
Node runs your JavaScript on one thread (why: Async / Await → the event loop). While renderToString runs, that process can't do anything else: not accept connections, not answer a health check. We measured renderToString on this machine (production React 19.3, Node 22):
| Page | Render time (median) | HTML size |
|---|---|---|
| 100-row table | ≈ 0.5 ms | 6 KB |
| 1,000-row table | ≈ 2.5 ms | 65 KB |
| 5,000-row table | ≈ 7.3 ms | 329 KB |
Those are tiny, simple components. Now do the arithmetic for a busier case: a page that costs 20 ms of CPU, at 100 requests per second, needs 100 × 20 ms = 2,000 ms of CPU every second, so two full cores just for rendering. Ways out: cache rendered HTML where it's shared (§07), render less (paginate; keep huge lists out of the HTML), and run several processes per machine. Streaming (§08) doesn't reduce the total CPU; it only sends the first bytes sooner.
5. Edge rendering and the distance to your data
Edge rendering runs your SSR code on CDN servers near the user. That shortens the trip between the user and the renderer, but if the page needs three sequential queries to a database in one region, each query now crosses an ocean. Three queries at 150 ms each is 450 ms before the first byte, worse than rendering next to the database.
Render close to your data, not close to the user, unless the data is also replicated near the user (or the page needs none). Static and cached pages (§07) are the ones that truly benefit from being at the edge.
6. Watching it: errors and timings from real users
Lab numbers like §03's tell you what's possible. What users actually get only shows up in the field (Observability covers collecting it):
- TTFB rising means the server or its data got slower; LCP rising with flat TTFB means the page got heavier; a widening gap between LCP and when the page becomes interactive means the bundle or hydration got heavier.
- Hydration mismatches don't break the page in production, so they're easy to miss. React fixes them by re-rendering (§06) and moves on. By default it passes the error to the browser's
reportError(), so it shows up as an uncaught error in the console, with only a code: "Minified React error #418" (production builds replace React's messages with numbers to save bytes; the code links to the full text on react.dev). Monitoring tools that listen for global errors may pick it up. To report it on purpose, with the component stack, pass your own handler. Note that it replaces the default reporting:
hydrateRoot(document.getElementById("root"), <App dishes={window.__DATA__} />, {
onRecoverableError(error, info) {
// Called for hydration mismatches and other errors React recovered from by re-rendering.
reportToMonitoring("recoverable", error.message, info.componentStack);
},
});- Have a fallback. If the server render fails (
onShellErrorin §08), serve the static CSR shell instead of an error page. The user gets a slower page instead of none; your alert tells you to fix it.
Pitfalls
Each of these comes from a rule earlier in the topic. If a server-rendered page "acts weird", check this list first.
“window is not defined” on the server
function Banner() {
const wide = window.innerWidth > 800; // ✕ crashes during server rendering
return wide ? <BigBanner /> : <SmallBanner />;
}There's no window, document or localStorage in Node. Code that touches them during render crashes the server render. Wrapping it in typeof window !== "undefined" stops the crash but causes a hydration mismatch instead (§06). Fix: use CSS media queries for layout differences, or read browser-only values in an effect and render a neutral version first.
Expecting useEffect to run on the server
Effects never run during server rendering. A component that loads its data in useEffect sends its loading state in the HTML, so the user and the crawler see "Loading…" even with SSR. Load page data on the server (pass it in as props, or with use() + Suspense, §08), and keep effects for browser-only work.
Fetching the same data twice
The server fetched the dishes to render the HTML, and then the browser fetches them again on hydration, because the component has a useEffect(() => fetch(…)). Twice the load, and if the data changed in between, a mismatch. Send the server's data along with the page (§04) and hydrate with it; frameworks do this for you.
Silencing mismatches instead of fixing them
suppressHydrationWarning everywhere makes the warnings stop, but the user keeps seeing the server's text, and attribute mismatches are never patched (§06). Use it only for a single timestamp-like element. Fix the cause everywhere else.
A secret in a client file
With Server Components (§09), a file marked "use client" and everything it imports are sent to the browser. Importing your database module or reading process.env.STRIPE_SECRET there puts it in the bundle, readable by anyone. Keep secrets in server-only modules (many frameworks support a server-only import that makes the build fail if a client file pulls it in) and pass only the data a component needs.
Caching HTML that contains user data
A shared cache (CDN) given a personalised page serves one user's page to others (§12). Personal HTML gets Cache-Control: private, no-store; shared HTML stays free of personal data.
Server-rendering pages that don't need it
A page that's the same for everyone and changes daily doesn't need a render per request. SSR it and you pay for servers, add a database query to every visit, and get a slower first byte than a static file would (§03). Prefer SSG or SSG + revalidation (§07) unless the page is personal or must be exactly current.
Forgetting the gap before hydration
SSR made the page appear sooner, so the team moved on. But on slow phones the Uncanny valley lasts seconds, and taps during it are lost (§05). Measure when the page becomes interactive, not just when it appears; make key actions real <form>s that work without JavaScript; and shrink what hydrates (§09–10).
Check yourself
Commit to an answer before you click. If you miss one, go back to the section and the live demo it points to, and make it happen in front of you.
You open a client-rendered React app built with Vite and press Ctrl+U (view source). What's inside <body>?
An SSR page has painted a Buy button, but app.js is still downloading. The user clicks it. In the simplest setup (one hydrateRoot call, no Suspense boundaries), what happens?
The server runs in UTC. A user in Tokyo opens the page at 15:00 local time, which is 06:00 UTC. What happens during hydration?
function Greeting() {
const hour = new Date().getHours();
return <p>{hour < 12 ? "Good morning" : "Good afternoon"}</p>;
}A documentation site: 2,000 pages, the same for everyone, updated when the team merges a pull request.
A streamed page has two Suspense boundaries: Reviews (data in 1.2 s) and Recommendations (data in 2.4 s). When does the heading at the top reach the browser?
In a framework that uses React Server Components, a Server Component renders <Price amount={9} />. What happens?
// Price.jsx (no "use client" at the top)
export function Price({ amount }) {
const [currency, setCurrency] = useState("EUR");
return <button onClick={() => setCurrency("USD")}>{amount} {currency}</button>;
}Cheat sheet
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank page for seconds on phones | CSR waterfall: bundle → run → fetch (§03) | SSR/SSG the page, or split the bundle |
| Link preview / search result is empty | Content is built by JavaScript (§02) | Server-render or pre-render public pages |
| Page looks ready, taps do nothing | Hydration hasn't finished (§05) | Ship less JS (§09–10), real forms, streaming (§08) |
| "Hydration failed because the server rendered text didn't match the client" | Render depends on time, locale, random or window (§06) | Same inputs on both sides, or fill in after hydration |
| Wrong class or link stays after load | Attribute mismatch: never patched (§06) | Fix the cause; it's silent in production |
| "window is not defined" in server logs | Browser API used during render (§13) | Move it to an effect; use CSS for layout |
| Server-rendered page is slow to start | Waiting for the slowest query (§04) | Stream it: slow parts in Suspense (§08) |
| User sees someone else's name | Personal HTML in a shared cache, or request data in a module variable (§12) | private, no-store; pass request data via props/context |
| Price on a static page is out of date | SSG until the next build (§07) | Revalidation window + on-demand revalidation |