cf.completefrontend
My progress
FUNDAMENTALS · MODULE 7

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.

Two phone screens one second after tapping a link to a product page. Left, client-side rendering: the screen is blank, because the 400 KB bundle is still running and the product has not been fetched yet. Right, server-side rendering: the header, product photo, name, price of 129 dollars, and an Add to cart button are already painted, though the button does not work until hydration.CSR at 1.0 sSSR at 1.0 sshop.example/p/42blankJS still runningshop.example/p/42Trail Runner 2$129Add to cartdescription
One second in, the client-rendered page still shows nothing while the server-rendered page shows the product. The SSR page only looks finished: its button starts working after the JavaScript arrives and hydrates.

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 a and form elements 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.

  1. Route. Match the URL to a page and read the path parameters (id = 42), query, cookies, and headers. About 1 ms.
  2. 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.
  3. Render. Run the component tree with that data to produce an HTML string. A medium page takes 10 to 60 ms of CPU.
  4. Serialize state. Write the data it used into the page as JSON, so the browser does not fetch it again.
  5. Send. Return text/html with cache headers; the head links the CSS and JavaScript.
A sequence diagram with three lifelines: browser, SSR server, and APIs. The browser sends GET /products/42. The server sends three API calls at once and the slowest answers after 120 ms. The server renders for 40 ms, then sends 200 OK with the HTML and serialized data. Counting connection setup, the first byte reaches the browser at about 400 ms and content is painted at about 530 ms. Then the browser requests the JavaScript bundle, receives it, hydrates, and the page becomes interactive at about 1.5 seconds.BrowserSSR serverAPIsGET /products/42product, prices, reviewsslowest: 120 msrender: 40 ms200 HTML + dataTTFB 400 mspaint 530 msGET product.jsJS bundlehydrateinteractive at 1.5 s
Data fetching and rendering happen before the first byte, so they set TTFB. Content shows as soon as the HTML and CSS are in, but the page responds only after the JavaScript downloads and hydration runs.

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

html
<!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>
A trimmed SSR response for /products/42. The state script is what hydration reads.

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.

A waterfall of one page load on a mid-range phone, CSR above SSR, on an axis from 0 to 1.6 seconds. CSR: the HTML shell arrives by 0.25 s, the JavaScript downloads until 0.69 s and runs until 1.17 s, the phone calls the API until 1.37 s and renders until 1.53 s, when content shows and the page is interactive. SSR: server work, including data and render, ends at 0.4 s, which is the TTFB; HTML and CSS give content at 0.53 s; the JavaScript downloads until 0.83 s and hydration runs until 1.47 s, when the page is interactive.CSR: TTFB 0.25 s, content 1.53 s, interactive 1.53 sHTML shellJS downloadJS runAPI callrenderSSR: TTFB 0.40 s, content 0.53 s, interactive 1.47 sserver workHTML + CSSJS downloadhydrate0 s0.5 s1 s1.5 s
SSR's first byte comes later (0.40 s against 0.25 s), but its content comes a full second earlier. Both become interactive at about the same time, because both run the same JavaScript; with SSR the warm hydrate bar is the time the page looks ready but is not.

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.

MetricCSRSSRWhy
TTFBLow (static shell, often from a CDN)Higher (waits for data and render)The server cannot start the body until it has the content
FCPLate (after JS runs)Early (right after HTML and CSS)Content is in the first response
LCPLate, often the worst metricEarly if the LCP element is in the HTMLThe browser finds the hero image URL while parsing
INPDepends on bundle sizeDepends on bundle size, plus hydrationHydration is a long task; early taps wait for it
CLSRisk of shifts as data loadsLower, the layout arrives completeFewer 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.

Two timelines on a mid-range phone. CSR: HTML at 250 ms, blank until 1530 ms, then interactive. SSR: HTML at 400 ms, content at 530 ms, interactive at 1470 ms.blanklooks readyinteractiveHTML arrivesCSRSSR0s0.5s1s1.5s2s2.5s3s
A simple model: 4G with 80 ms round trips, 10 Mbps, a phone about 4 times slower than the server at rendering, and 1.2 ms of script work per KB of bundle. The tick is TTFB. Hatched is blank, warm is looks ready but ignores taps, green is interactive.

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.

  1. Read the state. Parse the serialized JSON, so components get the same data the server had.
  2. 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.
  3. 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.
  4. Attach event handlers. React listens once at the root; Vue attaches them per element. Now onClick works.
  5. Run effects. useEffect or onMounted run for the first time: subscriptions open, timers start, analytics fire.

What hydration doesStep 1 of 7: Server HTML on screen

A product page is hydrated in steps: server HTML on screen, the state read, every component run, the DOM matched, handlers attached, effects run, and what happens on a mismatch.DOM from the serverh1: Trail Runner 2p: $129button: Add to carttime: 14:02 UTCno JS yettap ignored
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.

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 state machine for a server-rendered page. Blank goes to Painted, inert when the HTML and CSS arrive. Painted, inert goes to Hydrating when the JavaScript has loaded. Hydrating goes to Interactive when handlers are attached. If text differs during hydration, Hydrating goes to Mismatch, where that part is rendered again on the client, and then to Interactive. A note says taps do nothing in the painted state, except native links and forms.Blankwaiting for HTMLPainted, inertlooks readyHydratingmain thread busyInteractivehandlers attachedMismatchclient re-renderHTML + CSSJS loadedhandlersattachedtextdiffersTaps do nothing here,except native linksand forms
The page spends real time in "Painted, inert", where it looks done but ignores most input. Native 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's useId.
  • Browser-only checks in render. typeof window branches render differently on each side. Move browser-only output into an effect.
  • Invalid HTML. A div inside a p: 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.
tsx
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>;
}
Rendering a time safely: the server and the first client render agree, then an effect fills in the local value.

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.

A bar chart, to scale, of what one product page ships before compression. The markup in the HTML is 60 KB. The same data again as JSON state is 45 KB. The component code needed to re-run it in the browser is 400 KB. If the server serializes only the fields components need, the JSON drops to 8 KB.one product page, KB before compressionmarkup in the HTML60 KBsame data as JSON state45 KBcomponent code to re-run it400 KBJSON with only needed fields8 KB
The content arrives as markup, again as data, and once more as the code that renders it. Trimming the serialized state is the cheapest fix; cutting the code needs islands or server components.

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.

Two server timelines for the same page. Serial awaits: the user call takes 0 to 40 ms, the product call 40 to 120, the reviews call 120 to 240, and rendering 240 to 280 ms. With Promise.all: user, product, and reviews all start at 0 and end at 40, 80, and 120 ms, and rendering runs from 120 to 160 ms.serial awaits: render starts at 240 msuserproductreviewsrenderPromise.all: render starts at 120 msuserproductreviewsrender0 ms100 ms200 ms300 ms
Same calls, same APIs. Starting them together makes the wait equal to the slowest call, 120 ms, instead of the sum, 240 ms. Each serial 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 async and fetch their own data. A parent that awaits first still delays its children, so start promises early and await them with Promise.all. Identical GET fetch calls 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 load functions run in parallel unless one calls await parent().
  • Nuxt. useFetch and useAsyncData run on the server and put the result in the payload, so hydration does not refetch. Awaiting them one by one runs them in series.
tsx
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} />;
}
Next.js App Router: start all three requests, then await them together.

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?

Everything, then render

The server awaits every call the page needs

One complete HTML response

Pros

  • Simplest model
  • Complete page for crawlers
  • No loading states on first view

Cons

  • TTFB equals the slowest call
  • One slow service slows every page
  • Wasted work for content below the fold

Critical on the server, rest in the browser

Server fetches what the first screen needs

Below-the-fold widgets fetch after hydration

Pros

  • TTFB depends only on fast, critical calls
  • Lower server cost per request

Cons

  • Extra round trips from the browser
  • Loading states and possible layout shift
  • Crawlers miss the client-fetched parts

Stream: send the shell, fill in slow parts later

Server sends the head and fast content at once

Slow sections arrive later in the same response

Pros

  • Low TTFB and complete content in one response
  • Slow services no longer block the first paint

Cons

  • Needs a framework and hosting that support streaming
  • Status code and headers are fixed once streaming starts

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.
An architecture diagram. The browser sends a request to a CDN at the edge, which keeps an HTML cache and answers hits in 10 to 30 ms. On a miss the request goes to the origin region, where the SSR server first checks a render cache of finished HTML, then reads data through a data cache in front of the APIs. A miss all the way costs 150 to 400 ms.browserEdgeCDNHTML cachehit: 10 to 30 msOrigin regionSSR serverrendersrender cachedata cacheAPIsmiss150 to 400 ms
Each layer that answers saves all the work behind it. The CDN is the big one: a hit never reaches your servers. The render cache saves CPU, and the data cache saves slow API calls when the HTML itself cannot be shared.

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
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"
A public product page: the CDN keeps it 60 s and serves a stale copy for up to 5 more minutes while it refreshes; browsers treat their copy as stale at once.

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: Cookie is 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 de from Accept-Language or variant=b from an experiment cookie, and key on that instead.
  • Strip noise from URLs. Remove tracking parameters such as utm_source from the cache key.
A flowchart for caching a server-rendered route. First: is the HTML the same for everyone? If yes, send public with s-maxage and stale-while-revalidate, so the CDN serves most views. If no: does it vary only by a few groups such as language, currency, or an experiment? If yes, put a segment in the cache key. If no: can the personal parts load later in the browser? If yes, cache a shared shell and fill them after hydration. If no, send private or no-store and render every request.Same HTML for everyone?no login, no user datayespublic, s-maxage, SWRCDN serves most viewsnoVaries by a few groups only?language, currency, A/B testyessegment in cache keyfew copies per URLnoPersonal bits can load later?name, cart badge, saved itemsyesshared shell + clientfill after hydrationnoprivate or no-storerender every request
Walk down until you hit a yes. Most public pages stop at the first or third box; only pages that are personal from top to bottom reach the bottom.

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) or no-store for sensitive pages like account settings and checkout.
  • Never cache a response that sets a cookie in a shared cache. A cached Set-Cookie with 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?

Render everything per user

SSR reads the session and renders the full page

Cache-Control: private

Pros

  • Simplest code
  • Complete page in one response, no flicker

Cons

  • No CDN caching: every view hits the origin
  • Highest server cost
  • Personal data slows down the whole page

Shared shell, personal parts in the browser

SSR renders the page with a neutral header and caches it publicly

After hydration the client calls /api/me and fills in the name and cart

Pros

  • Full CDN caching for the HTML
  • Personal API is small and fast

Cons

  • Personal parts appear a moment later
  • Reserve their space or the layout shifts

Vary on a small segment

The edge maps the cookie to a coarse value such as logged-in or country=DE

That value joins the cache key

Pros

  • Still cacheable, with a few copies per URL
  • Good for currency, language, experiments

Cons

  • Only works for coarse groups, never per user
  • Each new segment value lowers the hit ratio

Assemble at the edge

The CDN caches the shared page with marked holes

An edge function or edge includes fill the holes per request

Pros

  • Complete HTML and full caching of the shared part

Cons

  • More moving parts and vendor-specific features
  • Edge logic must be fast and safe

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.

HTML:
Cache-Control:
CDN cache key:

No visits yet. Every visit asks the CDN for the home page.

Visits in order: none yet.no visits yet
Each column is one visit: who asked, whether the CDN had it (hit), stored it (miss), or let it through (bypass), and the greeting they saw. Red means someone saw another user's name.

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.

One Node.js process with one main thread. Request 7 is rendering for 40 ms. Requests 8 to 13 wait in a queue; their renders start after 40, 80, 120, 160, 200, and 240 ms of waiting, so the last one's TTFB grows by 240 ms even though each render takes only 40 ms.one process, one main threadrendering#7: 40 msqueued requests, oldest first#8+40#9+80#10+120#11+160#12+200#13+240ms of waiting before its render starts25 renders per second per core at 40 ms each
Rendering is CPU work, so requests in one process take turns. Near capacity the queue grows, and p95 and p99 TTFB climb long before average CPU looks alarming.

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

ProblemHow you noticeFix
A dependency is down or slow500 errors or TTFB spikes on every page that uses itTimeouts per call, fallbacks for non-critical data, error boundaries that render the rest of the page
State shared between requestsUser A briefly sees user B's data; bugs that only appear under loadCreate stores, caches, and clients per request, never as module-level variables that hold user data
Browser APIs in renderwindow is not defined crashes on the serverUse them only in effects or event handlers, or behind a client-only boundary
Memory leaksProcess memory grows until restarts; latency rises before each restartAvoid unbounded module-level caches; give caches a size limit; heap snapshots under load
Wrong status codesA missing product returns 200 with a "Not found" message, which search engines indexReturn 404 and 301 or 302 from the server; frameworks have notFound() and redirect helpers
Hydration mismatchesConsole errors, flashes of changed content, worse INPRemove 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.

FrameworkWhere server data loadsHow a route opts out of the server or into caching
Next.js (App Router)async server components fetch directlyPrerenders 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 routesReturn Cache-Control from route headers; client-only parts load in clientLoader
NuxtuseFetch and useAsyncData in componentsRoute rules set per path: SSR, prerendered, cached with revalidation, or client-only
SvelteKitload 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.

QuestionCSRSSR
First paint on a slow phoneAfter JS download, run, and data fetchAfter HTML and CSS
Time to first byteLow, static file from a CDNHigher, waits for data and render
JavaScript the browser runsThe full appThe full app, plus hydration checks
SEO and link previewsDepends on crawlers running JSContent in the first response
Server costStatic hostingCompute per uncached request
Caching the HTMLTrivial, one shell for allPossible for shared pages, hard for personal ones
Later navigationsClient-sideClient-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

  1. The boxes: browser, CDN, SSR server, and the APIs, in the same region as the server.
  2. The first request: URL in, data fetched in parallel, HTML out, then JS and hydration. Say which data is critical.
  3. The cache line: which routes are shared and cached with s-maxage, which are private, and how personal bits reach shared pages.
  4. 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

LevelWhat a strong answer shows
JuniorKnows SSR sends rendered HTML and the browser hydrates it; links it to SEO and first paint; knows the page is not interactive until hydration
MidExplains TTFB versus FCP and LCP; fetches server data in parallel; caches shared pages at the CDN; keeps personal data out of shared HTML
SeniorChooses 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
StaffFrames 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?

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-maxage and stale-while-revalidate; caching is what makes SSR affordable at scale.
  • Personal data breaks shared caching: keep it out of shared HTML, send private or no-store when it is in, never cache Set-Cookie, and avoid Vary: 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?

Question 2

We moved from CSR to SSR and TTFB got worse. Did we make a mistake?

Question 3

How do you cache a server-rendered page that shows the user's name in the header?

Question 4

What causes hydration mismatch errors, and how do you fix them?

Question 5

How many servers would you need to server-render a page with 5,000 requests per second at peak?

Practice it

These design problems lean on server-side rendering.

References

Read next