Server-side rendering
The server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
The problem SSR solves
Picture a product page on an online shop, built as a single-page app. A shopper taps a link from a search result on a mid-range phone over 4G. The server answers quickly, but what it sends is an almost empty HTML file: a div with the id root and a script tag pointing at a 400 KB JavaScript bundle.
For the next second and a half the screen is white while the phone downloads, parses, and runs the bundle, then asks the API for the product. On a weak signal or an older phone it is easily 3 seconds. Some shoppers press Back before anything appears.
That is client-side rendering (CSR), covered in the lesson "Client-side rendering": fine for apps people keep open all day, poor for a page whose first visit matters, like a product page, an article, or a landing page.
The idea: send the page, not the instructions
Server-side rendering (SSR) moves the first render to the server. When the request arrives, the server fetches the product, runs the same components the browser would run, and turns them into an HTML string. It sends that string. The browser can paint the product name, price, and photo as soon as the HTML and CSS arrive, before a single line of the app's JavaScript has run.
The JavaScript still comes. When it arrives, it takes over the HTML that is already on screen and makes it interactive: the "Add to cart" button starts working, the image carousel starts swiping. That takeover step is called hydration, and much of this lesson is about it, because it is where most of the hidden cost of SSR lives.
What SSR buys you
- Content without JavaScript. The first paint does not wait for the bundle, which on a slow phone can take a second or more to run.
- One trip for data. The server sits a millisecond or two from the APIs; a phone on 4G is 50 to 100 ms away per round trip.
- Crawlers and link previews see the content. Search engines render JavaScript more slowly and less reliably than they read HTML, and many link preview bots do not run it at all.
- Some of the page works before the JavaScript. Real
aandformelements work even if the bundle is slow or fails.
What it costs
Nothing here is free. The server works on every request, and a slow API now slows the whole page. The browser still runs the full bundle to hydrate, so the page can look ready before it is. And HTML that holds one user's data cannot be shared through a cache.
One request, step by step
The mental model to keep is short: HTML per request. Every time someone asks for a URL, the server builds that page from fresh data, just for this request, and throws the result away when it is sent.
A restaurant comparison helps. CSR is a meal kit: the box arrives fast, but you cook it yourself. Static generation is a sandwich made this morning and kept in the fridge: instant, but it is the same sandwich for everyone. SSR is a cooked-to-order dish: it takes the kitchen a moment, but it arrives ready to eat and made for you.
What the server does
Follow a request for /products/42 through a typical SSR framework. The numbers are a model, typical for a Node.js server in the same region as its APIs.
- Route. Match the URL to a page and read the path parameters (
id = 42), query, cookies, and headers. About 1 ms. - Fetch data. Call the product, price, and review services. Each takes 10 to 50 ms in the same data center; run together, the slowest one wins.
- Render. Run the component tree with that data to produce an HTML string. A medium page takes 10 to 60 ms of CPU.
- Serialize state. Write the data it used into the page as JSON, so the browser does not fetch it again.
- Send. Return
text/htmlwith cache headers; the head links the CSS and JavaScript.
What the browser does
The browser treats this like any other HTML page: it parses the HTML, downloads the CSS from the head, and paints. The product name, price, and photo appear. This is the moment SSR exists for.
Meanwhile the bundles download, usually with defer or as modules so they do not block parsing. When they arrive, the framework reads the serialized data, runs the components again, matches them to the HTML on screen, and attaches event handlers.
So SSR is mostly about the first page load. After hydration, in-app links usually fetch data and render on the client: an SSR app is server-rendered at the entry and client-rendered after that.
What the response actually contains
<!doctype html>
<html lang="en">
<head>
<title>Trail Runner 2 | Shop</title>
<meta name="description" content="Light trail shoe, 240 g">
<link rel="stylesheet" href="/assets/app.3f9a.css">
<script type="module" src="/assets/product.81c2.js"></script>
</head>
<body>
<div id="root">
<h1>Trail Runner 2</h1>
<p class="price">$129</p>
<button type="button">Add to cart</button>
</div>
<script id="__state" type="application/json">
{"product":{"id":42,"name":"Trail Runner 2","price":129}}
</script>
</body>
</html>
Three parts matter: the markup the user sees at once, the module script with the app code, and the JSON with the data the server used. Every framework ships all three, under its own names.
Check your understanding · Exercise 1
A user opens /products/42 on an SSR site, then clicks a link to /products/43 inside the app. Which statements are true for a typical modern framework? Select all that apply.
TTFB, FCP, LCP, and INP: what SSR moves
SSR does not make a page faster in every way; it moves time around. Four metrics show where, each measured from the start of the navigation.
- TTFB, time to first byte: when the first byte of the HTML response reaches the browser. It includes DNS, connection setup, the request's trip, all the server's work, and the first byte's trip back.
- FCP, first contentful paint: when the browser first paints any text or image from the page.
- LCP, largest contentful paint: when the biggest text block or image in the viewport is painted. It is one of the three Core Web Vitals; Google calls 2.5 s or less good.
- INP, interaction to next paint: how long the page takes to visibly respond to taps, clicks, and key presses, across the whole visit. Also a Core Web Vital; 200 ms or less is good.
TTFB goes up
With CSR the server sends a static shell, often from a CDN, so TTFB is mostly network: connection setup and one round trip. With SSR the server cannot send the first byte until it knows what to put in it, so TTFB also includes data fetching and rendering. In the model this lesson's figures use, a phone with 80 ms round trips, CSR's TTFB is 250 ms and SSR's is 400 ms: the same 240 ms of network, plus 120 ms of API calls and 40 ms of rendering.
FCP and LCP usually go down a lot
With SSR the content is in the first response, so FCP comes 100 to 300 ms after TTFB. If the largest element is text, LCP lands then too; if it is a hero image, the browser finds its URL in the HTML at once instead of after the JavaScript runs.
With CSR, FCP and LCP wait for the whole chain: HTML, JavaScript download, run, API call, render, image. On a slow phone that is easily 2 to 4 seconds.
INP can get worse, at first
SSR does nothing for interactivity on its own. The page is inert until hydration finishes, and hydration is a long task that can block the main thread for hundreds of milliseconds on a mid-range phone. A tap in that window does nothing or waits in the queue.
So INP depends on how much JavaScript you hydrate. Streaming SSR, islands, and React Server Components are all partly answers to that problem.
| Metric | CSR | SSR | Why |
|---|---|---|---|
| TTFB | Low (static shell, often from a CDN) | Higher (waits for data and render) | The server cannot start the body until it has the content |
| FCP | Late (after JS runs) | Early (right after HTML and CSS) | Content is in the first response |
| LCP | Late, often the worst metric | Early if the LCP element is in the HTML | The browser finds the hero image URL while parsing |
| INP | Depends on bundle size | Depends on bundle size, plus hydration | Hydration is a long task; early taps wait for it |
| CLS | Risk of shifts as data loads | Lower, the layout arrives complete | Fewer late-arriving blocks push content around |
Try the numbers
The lab below models one page load on a phone. Change how slow the APIs are, how long rendering takes, and how big the bundle is, and compare the two approaches. Notice two things: slow APIs hurt SSR's TTFB directly, and a big bundle widens the gap between "looks ready" and "is ready".
CSR: first byte at 250 ms, content and interactive at 1530 ms. SSR: first byte at 400 ms, content at 530 ms, interactive at 1470 ms. The SSR page looks ready but ignores taps for 940 ms.
Check your understanding · Exercise 2
A news site moves its article pages from CSR to SSR. Which changes would you expect in real-user data? Select all that apply.
Hydration and its cost
The HTML from the server is just markup. It has no click handlers, no component state, no effects. Hydration is the step where the framework, running in the browser, connects its components to that existing markup instead of creating new DOM. Done right, the user sees no change on screen; the page simply starts responding.
What hydration actually does
In React the entry point is hydrateRoot(container, element) instead of createRoot(container).render(element). Vue uses createSSRApp and mounts onto the server markup; SvelteKit and Nuxt do the equivalent for you. Under every API name the work is the same.
- Read the state. Parse the serialized JSON, so components get the same data the server had.
- Run every component again. Every component and hook executes in the browser, rebuilding the in-memory tree (React's fiber tree, Vue's virtual DOM) needed for later updates.
- Walk the DOM alongside the tree. Instead of creating elements, claim each existing DOM node, in order, for the matching output. Tags, order, and text must match.
- Attach event handlers. React listens once at the root; Vue attaches them per element. Now
onClickworks. - Run effects.
useEffectoronMountedrun for the first time: subscriptions open, timers start, analytics fire.
What hydration doesStep 1 of 7: Server HTML on screen
h1, a price, a button, and a time, but no event handlers and no component state. A tap on the button does nothing.Server HTML on screen: The HTML is painted. The DOM has an h1, a price, a button, and a time, but no event handlers and no component state. A tap on the button does nothing.
Nothing is skipped because the server already did it. Hydration is close to a full client render minus the DOM writes, so a 400 KB bundle hydrating a long page can block a mid-range phone for hundreds of milliseconds.
The gap between looking ready and being ready
Between first paint and the end of hydration, the page looks finished but ignores the user. A tap on "Add to cart" does nothing; some people tap again, some leave. This gap is sometimes called the uncanny valley of SSR.
Three things shrink it: ship less JavaScript, make key controls work without it (a real a, a real form with an action), and hydrate in pieces, which is what streaming SSR and islands do.
a and form elements are the exception, which is why they matter for SSR.Hydration mismatches
Hydration assumes the browser's first render produces exactly what the server produced. When it does not, you get a hydration mismatch.
In React 18 and 19, a text or structure mismatch is a recoverable error. React reports it (to the console by default, or to the onRecoverableError option), throws away the server HTML up to the nearest Suspense boundary, or the whole root if there is none, and renders that part again on the client. Attribute differences only produce a warning in development and are not fixed.
Vue warns in development and patches the DOM to match. Either way you lose part of the benefit of SSR, and the user may see a flash as content changes.
- Time.
new Date().toLocaleString()runs at another moment, often in another time zone: "14:02" on a UTC server, "19:32" in India. - Randomness.
Math.random()or random ids. Use the framework's id helper, such as React'suseId. - Browser-only checks in render.
typeof windowbranches render differently on each side. Move browser-only output into an effect. - Invalid HTML. A
divinside ap: the parser fixes the structure, so the DOM no longer matches. - Outside changes. Extensions or a CDN that rewrites HTML change the DOM before hydration sees it.
function LastUpdated({ iso }: { iso: string }) {
const [local, setLocal] = useState<string | null>(null);
useEffect(() => {
setLocal(new Date(iso).toLocaleTimeString());
}, [iso]);
// Server and first client render both output the ISO text.
return <time dateTime={iso}>{local ?? iso.slice(11, 16) + " UTC"}</time>;
}
React also has suppressHydrationWarning for a single element whose text is expected to differ, such as a timestamp. Use it sparingly: it hides the warning, it does not make the content match, and React will not patch that text.
The double data cost
Look again at what an SSR page ships. The product description is in the HTML, so the user can read it, and in the serialized JSON, so the components can re-run with it. The code of every component that renders it is in the bundle, so hydration can run. One piece of content, three forms.
30 KB of review text becomes 30 KB of markup plus about 30 KB of JSON. Compression helps, but the browser still parses the JSON and runs the components, and for content that never changes after load that work produces nothing new.
React Server Components, islands, and resumability (Qwik's approach) attack this by not shipping code or data for parts that are never interactive. With plain SSR, serialize only the fields the client needs and keep large static content out of client components.
Check your understanding · Exercise 3
Which of these will cause a hydration mismatch if done during render? Select all that apply.
Fetching data on the server
With SSR, data fetching sits directly on the path to the first byte. Every millisecond an API takes is a millisecond the user stares at the previous page or a blank tab. So the way you fetch matters more than with CSR, where a slow API only delays one spinner.
The good news: the server is close
A phone on 4G pays 50 to 100 ms per round trip to your API, plus TLS setup on a new connection. Your SSR server, in the same data center, pays 1 to 2 ms of network per call and keeps connections open across requests. A page that needs four API calls can cost a phone 400 ms of pure waiting; on the server the same calls cost little more than the APIs' own processing time.
So deploy the SSR server in the same region as its APIs. Rendering at the edge, far from a database in one region, can make TTFB worse, because each data call now crosses the world.
Server waterfalls
A waterfall is a chain of requests where each one starts only after the previous one finishes. On the server they creep in through structure: a layout awaits the user, then the page awaits the product, then a child component awaits the reviews. Each await looks harmless in its own file. Together they add up.
await looked harmless on its own.In the figure, the serial version takes 40 + 80 + 120 = 240 ms before rendering can start. The parallel version takes 120 ms, the time of the slowest call. Half the wait. Only true dependencies should stay serial: you cannot fetch the reviews for a product until you know its id, but you usually know it from the URL.
How the frameworks help
- Next.js App Router. Server components are
asyncand fetch their own data. A parent that awaits first still delays its children, so start promises early and await them withPromise.all. IdenticalGETfetchcalls in one render are memoized, so they run once. - React Router framework mode (formerly Remix). Each route has a
loader, and the loaders of all matched nested routes run in parallel. - SvelteKit. Page and layout
loadfunctions run in parallel unless one callsawait parent(). - Nuxt.
useFetchanduseAsyncDatarun on the server and put the result in the payload, so hydration does not refetch. Awaiting them one by one runs them in series.
export default async function ProductPage({ params }: Props) {
const { id } = await params;
const productP = getProduct(id);
const pricesP = getPrices(id);
const reviewsP = getReviews(id, { limit: 5 });
const [product, prices, reviews] = await Promise.all([
productP,
pricesP,
reviewsP,
]);
return <Product product={product} prices={prices} reviews={reviews} />;
}
When one dependency is slow
With parallel fetching, the slowest call's worst case becomes your worst case. If reviews have a p99 of 900 ms, one product page in a hundred waits nearly a second.
Sort data into critical (the page is useless without it: product name, price, stock) and nice to have (reviews, recommendations, recently viewed). Give the nice-to-have calls a short timeout, such as 150 ms, and render a fallback if they miss it. Or do not fetch them on the server at all.
Decision How much data should the server fetch before it sends the page?
What we pick: For most pages, fetch critical data on the server in parallel with timeouts on everything else. If slow sections still matter for SEO or the first view, stream them. The lesson "Streaming server rendering" covers how.
Forwarding who the user is
The browser sends its own cookies to APIs; the SSR server must forward the user's identity itself, by reading the session cookie and passing a token on. Forget it and logged-in users get the logged-out page; pass every incoming header through and you may leak cookies to third parties.
Check your understanding · Exercise 4
A layout awaits the user (50 ms). The page awaits the product (60 ms), then awaits reviews that need the product's category (90 ms). The product id is in the URL. What is the best TTFB you can reach for data, ignoring rendering? Pick one.
Caching SSR output
"HTML per request" is the mental model, but rendering the same product page 10,000 times a minute for 10,000 anonymous visitors is wasteful. If the HTML is the same for all of them, render it once and let a cache hand it out.
Where the HTML can be cached
- The CDN. An edge server near the user keeps a copy of the HTML and serves it in 10 to 30 ms. Your SSR server never sees the request. This is the biggest win and the main topic of this chapter.
- A render cache on the server. The SSR server keeps finished HTML (or rendered fragments) in memory or Redis, keyed by URL and the inputs that matter. It saves render CPU and API calls, but the request still travels to your origin.
- A data cache. The server caches API responses, not HTML. It still renders on every request, but skips the slow calls. Useful when the HTML varies per user but the underlying data, like a product, does not.
Telling the CDN what to do: s-maxage
The Cache-Control header controls both browsers and shared caches such as CDNs. max-age applies to every cache. s-maxage applies only to shared caches and overrides max-age there. That split is what SSR pages want: let the CDN keep the page for a minute, but give the browser max-age=0, so its own copy is stale at once and it asks again.
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
Vary: Accept-Encoding
ETag: "p42-v1817"
With stale-while-revalidate=300, after 60 seconds the CDN keeps serving the old copy while it fetches a fresh one in the background, so users almost never wait for a render. CDN support for this directive varies; check your provider. Browsers read it too and may briefly show a stale copy while they refresh; if that matters, put the CDN's rules in CDN-Cache-Control and send browsers no-cache, as the lesson "HTTP caching" shows.
The numbers add up fast. A page with 1,000 requests per second and a 60 second CDN lifetime is rendered about once a minute per edge location: with 300 locations, about 5 renders per second instead of 1,000.
Cache keys and Vary
A cache stores each response under a cache key, by default the method and the full URL. Two requests with the same key get the same copy. If your HTML depends on something that is not in the URL, the key must include it, or users get each other's pages.
The Vary header tells caches which request headers are part of the key. Vary: Accept-Language means the English and the German page are stored separately. It is correct, but every value you vary on splits the cache: 20 languages means 20 copies and a lower hit ratio for each.
Vary: Cookieis almost always a mistake on a CDN. Every visitor's cookie header differs (analytics ids, consent flags), so every request becomes its own entry and the hit ratio drops to near zero.- Normalize before you vary. Have the CDN derive a small value, such as
defromAccept-Languageorvariant=bfrom an experiment cookie, and key on that instead. - Strip noise from URLs. Remove tracking parameters such as
utm_sourcefrom the cache key.
Getting fresh content out
A short s-maxage (30 to 300 seconds) with stale-while-revalidate is the safe default. When an edit must show at once, purge: many CDNs let you tag responses (Fastly's Surrogate-Key, Cloudflare's Cache-Tag) and purge every page with a tag in one call. Next.js offers revalidatePath and revalidateTag; the lesson "Static generation and incremental regeneration" goes deeper.
Check your understanding · Exercise 5
Which Cache-Control values fit a public, server-rendered article page that should appear within a minute of edits? Pick one.
Personalization without losing the cache
A shared cache works because many users get the same bytes; personalization gives each user different bytes. Once the HTML says "Hi, Priya", it cannot be shared, and the CDN that saved 95 percent of your server work can no longer help.
Worse, if the response is still marked public, the CDN will share it anyway, and the next visitor sees "Hi, Priya" and her cart.
This is not hypothetical. On Christmas Day 2015, during a traffic attack, a caching configuration change made Steam's store serve pages generated for some users to others, exposing details such as billing addresses and purchase history for about 34,000 users. A missing private is a privacy incident.
The rules for personal pages
- If the HTML contains anything specific to one user, send
Cache-Control: private(the browser may keep it, shared caches may not) orno-storefor sensitive pages like account settings and checkout. - Never cache a response that sets a cookie in a shared cache. A cached
Set-Cookiewith a session id hands one user's session to everyone. Many CDNs refuse to cache such responses by default; do not rely on that default. - Set the header yourself on every SSR route and test it; do not rely on framework or CDN defaults.
Four ways to combine the two
Most pages are 90 percent shared content (the product, the article) and 10 percent personal content (the name in the header, the cart badge, "recommended for you"). The patterns below all try to cache the 90 percent and personalize only the 10 percent.
Decision How should a server-rendered product page show the user's name and cart count?
What we pick: For most public pages with light personalization, cache the shared HTML and fill personal parts in the browser, with space reserved so nothing shifts. Use segment keys for language, currency, and experiments. Render fully per user only for pages that are mostly personal, like a dashboard, and accept the server cost.
No visits yet. Every visit asks the CDN for the home page.
When the whole page is personal
Behind a login a shared cache cannot help and SEO does not matter, so many products use SSR for public pages and CSR, or a server-rendered frame, for the app itself.
Check your understanding · Exercise 6
A server-rendered home page shows trending items for everyone and a "Welcome back, name" banner for logged-in users. Which setups are safe? Select all that apply.
Server cost, failures, and security
With CSR, your HTML is a static file and the browser pays for rendering. With SSR, you pay, in servers and in new ways to fail.
Rendering is CPU work
Rendering to a string is pure CPU. Node.js runs JavaScript on one main thread per process, so while one request renders for 40 ms, every other request in that process waits. Data fetching overlaps because it waits on the network; rendering does not.
That gives you a quick capacity estimate. At 40 ms of render CPU per page, one core renders about 25 pages per second. Peak traffic of 2,000 uncached page views per second needs about 80 cores just for rendering, plus headroom of 30 to 50 percent so the queue never builds up.
With a 95 percent CDN hit ratio the origin sees 100 requests per second: four cores, or five to six with headroom. Caching is not an optimization for SSR at scale; it is the plan.
Watch p95 TTFB and event loop delay, not just CPU, and profile render time per route: one page with a 2,000 row table can cost more than ten normal pages.
What breaks in production
| Problem | How you notice | Fix |
|---|---|---|
| A dependency is down or slow | 500 errors or TTFB spikes on every page that uses it | Timeouts per call, fallbacks for non-critical data, error boundaries that render the rest of the page |
| State shared between requests | User A briefly sees user B's data; bugs that only appear under load | Create stores, caches, and clients per request, never as module-level variables that hold user data |
| Browser APIs in render | window is not defined crashes on the server | Use them only in effects or event handlers, or behind a client-only boundary |
| Memory leaks | Process memory grows until restarts; latency rises before each restart | Avoid unbounded module-level caches; give caches a size limit; heap snapshots under load |
| Wrong status codes | A missing product returns 200 with a "Not found" message, which search engines index | Return 404 and 301 or 302 from the server; frameworks have notFound() and redirect helpers |
| Hydration mismatches | Console errors, flashes of changed content, worse INP | Remove time, randomness, and browser checks from render; track hydration errors in monitoring |
The shared state row deserves emphasis. A store created once at module level, such as export const store = createStore(), is shared by every request in the process. In the browser there is one user; on the server it is a data leak.
Security: serialized state and secrets
The serialized state is HTML that contains user content. If a review says </script><script>alert(1)</script> and you write the JSON into the page with plain JSON.stringify, the browser ends your state script at that closing tag and runs the attacker's script. Escape < as \u003c (and a few other characters) when inlining JSON. Mainstream frameworks do this for you; hand-rolled SSR often forgets.
Secrets are the second risk. Anything passed to a client component or returned from a loader is serialized into the page for anyone to read, so keep keys in server-only modules and never pass whole user or config objects.
Accessibility and resilience
SSR helps accessibility in one clear way: assistive technology gets real headings, landmarks, and text from the first response, and the page does not announce a blank document followed by a burst of content. It also helps on flaky networks, where a failed bundle download leaves a readable page instead of a white screen.
It hurts in one way: controls that look ready but are inert before hydration. A keyboard user who presses Enter on a custom div button gets nothing. Prefer native button, a, and form elements, which have built-in behavior, and keep custom widgets that need JavaScript visually quiet, or disabled, until they are hydrated.
Check your understanding · Exercise 7
After launching SSR, support reports that a user saw another user's name in the header for a second, only during busy hours. What are likely causes? Select all that apply.
SSR in real frameworks and products
Few teams write SSR by hand today. A meta-framework handles routing, data loading, rendering, serialization, and hydration, each with its own names.
| Framework | Where server data loads | How a route opts out of the server or into caching |
|---|---|---|
| Next.js (App Router) | async server components fetch directly | Prerenders what needs no request data; reading cookies, headers, or uncached data renders that part per request; use cache, cacheLife, and cache tags control freshness |
| React Router framework mode (formerly Remix) | A loader per route, run in parallel for nested routes | Return Cache-Control from route headers; client-only parts load in clientLoader |
| Nuxt | useFetch and useAsyncData in components | Route rules set per path: SSR, prerendered, cached with revalidation, or client-only |
| SvelteKit | load in +page.server.ts (server only) or +page.ts (both) | export const ssr = false or prerender = true per route; setHeaders for caching |
Every framework treats SSR as a per-route choice: a marketing page prerendered, a product page server-rendered and cached, a settings page client-rendered, all in one app.
Where SSR is the usual choice
- E-commerce. Product and category pages need fast LCP on phones and full content for search engines. Shopify's Hydrogen storefront framework is built on React Router (previously Remix) and server-renders by default. Large shops cache product HTML at the CDN and fill cart and account details on the client.
- News and publishing. Articles must be readable in the first response and in link previews, and they are the same for most readers, so CDN hit ratios are high.
- Marketplaces and listings. Search result pages depend on query parameters but are shared across users: SSR with short CDN lifetimes and normalized query keys.
- Logged-in apps, partly. Mail clients and dashboards often server-render only the frame and first screen, then run as a client app.
SSR compared with CSR, honestly
The "Client-side rendering" lesson ended with the cases where CSR wins: long sessions, logged-in tools, offline-capable apps, and teams that want a static deploy. Those still hold. SSR is not an upgrade to CSR; it is a different set of trade-offs.
| Question | CSR | SSR |
|---|---|---|
| First paint on a slow phone | After JS download, run, and data fetch | After HTML and CSS |
| Time to first byte | Low, static file from a CDN | Higher, waits for data and render |
| JavaScript the browser runs | The full app | The full app, plus hydration checks |
| SEO and link previews | Depends on crawlers running JS | Content in the first response |
| Server cost | Static hosting | Compute per uncached request |
| Caching the HTML | Trivial, one shell for all | Possible for shared pages, hard for personal ones |
| Later navigations | Client-side | Client-side, same as CSR |
Later rendering patterns each fix one of SSR's costs: static generation and ISR remove the per-request render for shared pages, streaming SSR removes the wait for slow data, and islands and React Server Components cut the hydration bill. The lesson "Choosing a rendering strategy" puts them side by side.
Check your understanding · Exercise 8
A travel site has a home page, city guides, a flight search results page, and a trip planner that logged-in users edit for an hour. Which choices are reasonable? Select all that apply.
Using SSR in a system design interview
Rendering strategy is one of the first architecture choices in a frontend design round, usually right after requirements. Bring SSR up whenever the prompt mentions any of these: public pages, search traffic, link sharing, slow phones or emerging markets, or a first-load metric such as LCP.
What to draw
- The boxes: browser, CDN, SSR server, and the APIs, in the same region as the server.
- The first request: URL in, data fetched in parallel, HTML out, then JS and hydration. Say which data is critical.
- The cache line: which routes are shared and cached with
s-maxage, which areprivate, and how personal bits reach shared pages. - The split after load: once hydrated, navigation is client-side.
What to say
Name the trade in one breath: "SSR gets us content in the first response, which helps LCP and SEO on the product page. It costs server compute and raises TTFB, so we cache the shared HTML at the CDN for a minute with stale-while-revalidate, and we load the cart badge on the client so the HTML stays shareable. The risk is hydration cost on slow phones, so we keep client components small." That paragraph covers performance, cost, caching, personalization, and hydration.
Then let the interviewer pick the depth: a slow review service (timeouts, streaming), cache leaks (private, no cached Set-Cookie), or server count (render time times peak uncached requests).
How deep to go by level
| Level | What a strong answer shows |
|---|---|
| Junior | Knows SSR sends rendered HTML and the browser hydrates it; links it to SEO and first paint; knows the page is not interactive until hydration |
| Mid | Explains TTFB versus FCP and LCP; fetches server data in parallel; caches shared pages at the CDN; keeps personal data out of shared HTML |
| Senior | Chooses rendering per route; budgets render CPU and hydration JavaScript; handles slow dependencies with timeouts or streaming; plans cache keys, Vary, and purging; estimates server count |
| Staff | Frames rendering as a cost and risk decision across teams; sets per-route budgets and monitoring; knows when SSR is the wrong tool and what the newer patterns change |
Think first
An interviewer asks you to design a public recipe site with 2 million recipes and a "saved recipes" heart on each one. How do you render the recipe page?
Server-render the recipe page: it is public, searched for, and shared, so content in the first response matters for LCP and SEO. Fetch the recipe and its nutrition data in parallel on the server; load comments after hydration or stream them.
Cache the HTML at the CDN with something like s-maxage=300, stale-while-revalidate=3600, and purge by recipe tag on edits. With 2 million recipes the long tail will miss often, so keep render time low and the origin sized for misses.
The heart is personal. Keep it out of the shared HTML: render it in a neutral state with its space reserved, and fill it after hydration from a small /api/saved?ids= call. Mark that response private.
Keep the hydration bill small: the recipe text is static, so only the heart, the servings scaler, and the timer need client code.
Summary
- SSR builds HTML per request on the server, so content shows after one round trip and before any app JavaScript runs; after the first load, navigation is usually client-side.
- SSR raises TTFB, lowers FCP and LCP, and does nothing for INP on its own: judge it by real-user LCP, not by TTFB.
- Hydration re-runs every component in the browser, matches the server DOM, and attaches handlers. It costs about as much CPU as a client render, ships data twice, and leaves a window where the page looks ready but is not.
- Mismatches come from code that differs between server and browser: time, randomness, browser checks, invalid HTML. Make the first client render identical and move browser-only values into effects.
- Fetch server data in parallel, close to the APIs, with timeouts on non-critical calls; serial awaits across layouts and components can quietly double TTFB.
- Cache shared HTML at the CDN with
s-maxageandstale-while-revalidate; caching is what makes SSR affordable at scale. - Personal data breaks shared caching: keep it out of shared HTML, send
privateorno-storewhen it is in, never cacheSet-Cookie, and avoidVary: Cookie. - Choose rendering per route: SSR for public, first-load-critical pages; CSR or a light server frame for long, logged-in sessions.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is hydration, and why is it expensive?
Hydration is the browser-side step where the framework takes over server-rendered HTML. It reads the serialized state, runs every component again to rebuild its in-memory tree, walks the existing DOM to match it, attaches event handlers, and runs effects.
It is expensive because it is almost a full client render minus the DOM writes. The browser still downloads, parses, and runs all the component code, and the data is shipped twice: once as HTML and once as JSON. On a mid-range phone, hydrating a large page can block the main thread for hundreds of milliseconds, which hurts INP and leaves the page looking ready while ignoring taps.
To reduce it: ship less client code, serialize only the fields components need, use native links and forms so early taps still work, and hydrate in pieces with streaming, islands, or server components.
Question 2
We moved from CSR to SSR and TTFB got worse. Did we make a mistake?
Not necessarily. TTFB is expected to rise because the server now fetches data and renders before it can send the body. What should improve is FCP and LCP, because the content is in the first response instead of arriving after the JavaScript runs and calls the API.
Check real-user FCP and LCP. If they improved, the trade worked. If TTFB is much worse than expected, look for serial data fetching, a slow dependency without a timeout, a server far from its APIs, or no CDN caching on shared pages.
Question 3
How do you cache a server-rendered page that shows the user's name in the header?
Keep the name out of the shared HTML. Render the page with a neutral header, cache it at the CDN with Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300, and fill the name after hydration from a small private API call, with the space reserved so the layout does not shift.
Never mark a response with personal data or a Set-Cookie header as public, and avoid Vary: Cookie, which destroys the hit ratio. For coarse variation such as language or an experiment bucket, derive a small value at the edge and add it to the cache key.
Question 4
What causes hydration mismatch errors, and how do you fix them?
A mismatch happens when the browser's first render differs from the server's HTML. Common causes are time and time zones, random values, typeof window checks in render, reading localStorage in render, invalid HTML nesting that the parser rewrites, and extensions or proxies that change the DOM.
Fix them by making the first client render match the server: use the same data and ids (React's useId), render browser-only values in an effect after hydration, pass the time zone or locale from the server, and fix invalid markup. React's suppressHydrationWarning is only for single, expected text differences like timestamps.
Question 5
How many servers would you need to server-render a page with 5,000 requests per second at peak?
Start with render CPU per request, say 30 ms, so one core handles about 33 renders per second. If every request reached the origin, that is about 150 cores, plus 30 to 50 percent headroom.
But most of those requests should be CDN hits. With a 95 percent hit ratio the origin sees 250 requests per second, about 8 cores before headroom. So the real answer is: cache shared pages, measure render time per route, and size the origin for misses and for personal pages that cannot be cached.
Practice it
These design problems lean on server-side rendering.
- Design a theme switcher with dark modeEasy
- Design a countdown timerEasy
- Design a podcast player with a synced transcriptMedium
- Design product ratings and reviewsMedium
- Design a feature flag SDKMedium
- Design an internationalization systemMedium
- Design Stack OverflowMedium
- Design WikipediaMedium
- Design a calendar week viewHard
- Design an A/B testing system for the frontendHard
- Design Twitter (X)Hard
- Design InstagramHard
- Design RedditHard
- Design LinkedInHard
- Design NetflixHard
- Design AirbnbHard
- Design an online judge like LeetCodeHard
References
- Rendering on the Web web.dev overview of SSR, CSR, static rendering, and hydration costs.
- hydrateRoot React reference, including hydration mismatches and suppressHydrationWarning.
- Time to First Byte (TTFB) What TTFB includes and how it relates to SSR.
- Largest Contentful Paint (LCP) The metric SSR improves most, with thresholds.
- Cache-Control MDN reference for s-maxage, private, and stale-while-revalidate.
- Vary MDN reference for cache keys built from request headers.
- Server-side rendering (Vue) Vue's guide, with a clear section on cross-request state pollution and hydration mismatch.
Read next
- Client-side renderingThe server sends an almost empty HTML page plus a JavaScript bundle, and the browser builds the whole page itself: easy to host and great behind a login, but slow to first content and weak for search.
- Static generation and incremental regenerationRender pages to HTML once, ahead of time, and serve the same files from a CDN to everyone; incremental regeneration then refreshes single pages after launch, so you get static speed without rebuilding the whole site for every edit.
- Streaming server renderingThe server sends a page's HTML in pieces as each part becomes ready, instead of waiting for its slowest data: the layout paints right away, slow sections stream in later, and each piece hydrates on its own.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- React Server ComponentsReact components that run only on the server and send their rendered result, not their code, to the browser: data fetching moves into components, heavy libraries stay off the client, and only components below a
"use client"boundary ship JavaScript.