Server-Side Rendering
The server fetches the data and renders the markup per request, so content arrives early. Interactivity does not arrive with it.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What does rendering on the server actually move, and what does it leave exactly where it was?
Someone opens a link — from a search result, a chat message, a bookmark — and wants to read the thing the link promised without waiting for an application to boot.
Run the same components on the server, turn the tree into an HTML string, send it. The user sees content immediately instead of a blank page, so this is the fast option and should be the default.
Content arriving early and the page working are different events, and server rendering only moves the first one. The markup is on screen and every button in it is inert until the same bundle downloads and hydrates (Hydration).
- Content arriving early and the page working are different events, and server rendering only moves the first one. The markup is on screen and every button in it is inert until the same bundle downloads and hydrates (Hydration).
- The first byte is now behind your server's slowest query. Client rendering sent an empty file instantly; server rendering holds the connection open while it does the work the client would have done, so the wait moved rather than vanished (Loading, Error, Empty — The States You Did Not Render).
- Rendering is now per request, per user, on infrastructure you operate. Traffic that a static file would have absorbed at an edge becomes CPU on an origin (Deploying a Frontend).
- Personalised HTML is hard to cache. The moment the markup contains a name, a price in a currency, a permission-dependent button or a locale-formatted date, a shared cache entry becomes a way to serve one user another user's page (Browser HTTP Caching).
- The components now run in two environments. Anything that touches
window,document,localStorageor a timezone-dependent API behaves differently or throws on the server, and the differences that do not throw become hydration mismatches (Hydration Mismatch). - The bundle did not get smaller. Every byte the client-rendered version shipped still ships, plus the serialized data embedded in the document so the client does not fetch it again.
What is actually happening
In the browser, not in the framework.
- On a request, the server resolves the route, fetches whatever that route needs, runs the component tree to produce a string of HTML, and writes it as the response body. There is no DOM involved on the server — the renderer produces markup directly (Reactivity Models describes the tree it walks).
- The same data is serialized into the document, usually as a script tag containing JSON, so that the client can build the identical tree without repeating the fetch. That serialized blob is the security surface of this strategy (Hydration).
- The browser receives a document with content in it and can parse, style, lay out and paint it with no JavaScript at all. This is the entire benefit, and it is genuinely large: text is readable and the accessibility tree is populated at first paint (The Accessibility Tree).
- The bundle then downloads, executes, rebuilds the same tree on the client and attaches event listeners and state to the existing DOM. Between paint and the end of that pass, the page looks finished and is not (Hydration).
- Time to the first byte now includes your server thinking. That is not a metric regression to be optimised away — it is the cost of the work moving, and it is only worth paying if what arrives is worth more than what a shell would have been.
- The server holds credentials, database connections and internal service access. It is the same process that decides what goes into the markup and what goes into the serialized state, which is why the boundary between "used to render" and "sent to the client" has to be explicit (What the Frontend Is Responsible For in Auth).
What this makes the browser do
And which of it is avoidable.
- Parse a much larger document. The markup that used to be one empty
divis now the whole page, and tree construction, style resolution and layout all scale with it (Tree Construction). - Parse the serialized state blob. It is JSON inside the document, it is parsed on the main thread, and on data-heavy pages it can be a substantial fraction of the bytes (The Real Cost of JavaScript).
- Run the entire application bundle anyway, then walk the whole tree again to attach behaviour — work the client-rendered version did once (Hydration).
- Avoidable: hydrating regions that are never interactive. That is the whole argument of Islands and Partial Hydration.
- Avoidable: waiting for the slowest region before sending anything, which is what Streaming Server Rendering addresses.
What moved, and what did not
Set the two strategies side by side and the picture is not "one is fast and one is slow". It is that server rendering pulls the data fetch and the first render onto the server, and pulls nothing else. The bundle is unchanged, the hydration pass is added, and the first byte now waits on work that used to happen after it.
That is a real trade and often a good one, but it is a trade. The routes where it obviously pays are the routes where somebody or something needs to read the page without running code. The routes where it obviously does not are the ones behind a login where the first view is personal, unindexable and followed by a long session.
- Data ready — On the server, before a byte is sent. This is why the first byte is later than a shell: the same query is being waited on, just somewhere else.
- HTML arrives — Later than a static shell, and it contains the content — so the wait bought information rather than nothing.
- Content visible — Readable, indexable and present in the accessibility tree, with no JavaScript executed. The entire point of the strategy is this row.
- JS downloaded — The same bundle the client-rendered version shipped, plus the serialized data. Server rendering did not remove it; it changed what it does.
- Hydration — The client rebuilds the tree to attach behaviour to markup that already exists — the duplicated work this strategy pays for its early content.
- Interactive — The widest gap in the module between looking ready and being ready. Everything is on screen and nothing answers, which people experience as the page ignoring them.
Compare the two timelines by the distance between Content visible and Interactive, not by their end points. Client rendering has no gap and arrives late; server rendering arrives early and opens a gap.
The serialization boundary
The renderer runs in a process that can reach the database, read secrets and call internal services. Everything it produces goes into a public document. That combination is the most common real vulnerability of this strategy, and it almost never looks like a mistake at the moment it is written.
The shape is always the same: a component needs one field, so it is handed the whole object, and the whole object ends up in the serialized state because the framework serializes what it was given. Nothing renders the extra fields, no test fails, and the response body contains them for every visitor.
// server
const user = await db.users.findById(session.userId)
// the whole row: passwordHash, internalNotes, stripeCustomerId,
// impersonatedBy, featureOverrides, email of the account manager...
return render(<Profile user={user} />)
// what ends up in the response body
<script id="__STATE__" type="application/json">
{"user":{"id":42,"name":"Ada","passwordHash":"$2b$...","stripeCustomerId":"cus_..."}}
</script>// server
const user = await db.users.findById(session.userId)
const view = {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
canManageBilling: can(session, 'billing:manage'),
}
return render(<Profile user={view} />)
// the response body now contains exactly the four fields the UI rendersThe serialized state is part of the response body, not an internal detail of the render. Projecting at the boundary makes the set of fields that become public an explicit, reviewable list instead of a consequence of which object someone had in scope. It also shrinks the document and the main-thread parse that follows it.
Personalization and the cache
The other structural cost is caching. A static file can be cached by every intermediary between your origin and the user; a per-request render usually cannot, and the boundary between the two is not a performance detail. Marking a personalised response cacheable by a shared cache is how one user is served another user's page.
The practical approach is per-route rather than per-application: decide, for each route, whether the response depends on who is asking. Routes that do not can be cached publicly and often should have been statically generated in the first place. Routes that do must be marked private, and any variation that changes the output — locale, currency, tenant — has to be reflected in the cache key or removed from the markup and rendered on the client instead.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A personalised route given a public cache directive during a performance push | Users report seeing another account's name, cart or permissions at the top of the page | A shared cache stored a response whose body depended on the session cookie, and served it to everyone matching the URL | Mark personalised responses private, and treat any response containing session-derived output as uncacheable by shared caches by default (Browser HTTP Caching) |
| A route that varies by locale or currency with no corresponding cache variation | A user in one region is served prices and dates formatted for another | The cache key was the URL, and the varying input was a header or a cookie the key ignored | Either include the varying input in the cache key or move the formatting to the client, where it can read the actual environment (Timezones and Locale Formatting) |
| A CDN in front of an origin that renders per request | Origin CPU tracks visitors linearly, and a traffic spike takes the page down entirely | Nothing is cacheable, so the CDN is a pass-through and the origin absorbs every view | Split the route: cache the parts that are identical for everyone and fetch the personal parts from the client, or generate statically and personalize after paint (Static Site Generation) |
| An authorization-dependent button rendered into cacheable markup | A control appears for users who cannot use it, and the click fails at the API | Permission-dependent markup was baked into a shared response | Never treat rendered UI as an authorization boundary — the server endpoint decides, and the UI reflects its answer (Authorization-Aware UI) |
How to build it
Most important first.
- Be explicit about what server rendering is for on each route: getting readable, indexable, assistive-technology-visible content into the first response. If a route needs none of those, it is paying per-request cost for nothing (Choosing a Rendering Strategy).
- Draw a hard line between server-only modules and shared ones. Database clients, secrets, internal SDKs and anything that reads an environment variable should be importable only from server entry points, enforced by tooling rather than by discipline (The Module Graph).
- Serialize the minimum. Send the fields the client actually renders, not the objects the server happened to have — this is a security control and a payload control at the same time (Server State Is Not Your State).
- Set caching headers deliberately per response. A public, non-personalised route can be cached at an edge; a personalised one must be marked private, and getting this backwards is a data-leak class of bug rather than a performance one (Browser HTTP Caching).
- Give the server a timeout and a fallback. If the data source is slow, deciding to send a shell or a partial page is better than holding the connection until the browser gives up (Network Failures Only the Client Can See).
- Keep the interactivity gap in view: shrink the bundle, split by route, and consider whether the page needs full hydration at all (Code Splitting, Islands and Partial Hydration).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- This is the strongest accessibility argument in the module: content rendered on the server is in the accessibility tree at first paint, before any JavaScript has run. Headings, landmarks, labels and text are all available to a screen reader immediately, and remain available if the bundle never arrives (The Accessibility Tree).
- It is also the origin of a specific accessibility failure: the page looks finished, so it invites interaction, and every control in it is inert until hydration completes. A keyboard user who tabs to a button and presses Enter before then gets nothing, with no feedback explaining why (Hydration).
- Server-rendered markup must still be semantic markup. Rendering a
divwith a click handler on the server produces adivwith no role and no keyboard behaviour, delivered faster (Semantics Are Behaviour). - Anything the server cannot know — the user's timezone, their reduced-motion preference, their viewport — must not change the semantics of what it renders, or the client will correct it after paint and a screen reader will have already announced the first version (Hydration Mismatch).
- Once the client router takes over, subsequent navigations announce nothing by default. Server rendering only helps the first document (Focus Management).
What can go wrong
- The slowest query becomes the page. One unindexed lookup in a sidebar widget holds the entire document, and the user sees nothing at all rather than a page with one slow region (Streaming Server Rendering).
- Server-only data leaking into the serialized state — an internal id, a full user record, an access token that was on the object you passed down. It is in the page source, and it is there for every viewer (Storage Security and Durability).
- A personalised response cached by a CDN or a proxy because the route was marked cacheable during a performance push. The symptom is users reporting someone else's name at the top of the page.
- Hydration mismatch turning the early paint into a flicker, a re-render, or a silently broken listener (Hydration Mismatch).
- The mitigation failing: adding server rendering to reduce the blank period, and increasing it instead, because the origin is now in the path and it is further from the user than the CDN was (CDN Delivery).
- An error thrown mid-render with the response already started, leaving a truncated document the browser will happily parse as far as it got (Streaming Server Rendering).
- A user interacts with server-rendered markup before hydration completes. The event fires against a DOM node whose listener does not exist yet, and unless input is recorded and replayed, it is simply lost (Hydration).
- The server renders with data that changes before the client hydrates, so the client's first render disagrees with the markup that is already on screen (Hydration Mismatch).
- A response begins streaming and an error occurs afterwards, so the status code has already been sent and cannot be changed (Streaming Server Rendering).
- The renderer runs in a process that holds secrets. The single most common vulnerability of this strategy is a server-only value reaching the serialized state because it was a property of an object that was passed into a component — the client never rendered it, but it is in the HTML source of every response (Storage Security and Durability).
- Serialized state is attacker-readable and attacker-inspectable. Treat the blob as a public API response and review it the way you would review one (How API Shape Drives UI Complexity).
- A personalised response must never be publicly cacheable. Mark it private, vary correctly on whatever distinguishes users, and remember that intermediaries you do not operate also honour those headers (CDN Delivery).
- Interpolating data into markup on the server reintroduces server-side injection: a framework that escapes by default protects you, and the escape hatch that renders raw HTML does not (Sanitization and Trusted HTML, Cross-Site Scripting).
- Rendering per request means an unauthenticated request can cause database work. Server rendering turns page views into origin load, which makes rate limiting a frontend architecture concern (Deploying a Frontend).
- "Server rendering makes the page fast." It makes content arrive earlier and interactivity arrive no earlier at all, while adding server work in front of the first byte. Whether that is faster depends on the route, the data and the device.
- "It is server-rendered, so we can ship less JavaScript." Nothing about rendering on the server removes a byte from the client. Shipping less is a separate decision (Islands and Partial Hydration).
- "The page is interactive because it is visible." This is the misconception the whole module exists to break (Hydration).
- "We render on the server, so the data is safe." The data is in the response body. Server rendering moves where the fetch happens, not who can read the result.
- "Just cache the HTML." For a personalised route that is a data leak, and for a public one you may have wanted static generation instead (Static Site Generation).
Measuring it, and what changes in the field
- Compare the time to the first byte of the document against the time content becomes readable. Server rendering deliberately increases the first and decreases the second; if only the first moved, the trade did not pay.
- Measure the gap between content arriving and the page responding to input. That gap is the thing server rendering does not fix, and it is the number most reports of "we added SSR and it still feels slow" are actually about (Interaction Responsiveness).
- View source, not the elements panel. The elements panel shows the hydrated DOM; view-source shows what actually arrived, which is the only way to see what a non-executing client gets (A Mental Model of the Devtools).
- Search that same source for anything that should not be public. This is a review step, not a debugging step, and it belongs in the deployment checklist (Deploying a Frontend).
- Watch origin CPU and response latency under load, because this strategy converts traffic into server work in a way a static deployment does not (Real User Monitoring).
- On a slow device, server rendering helps most: painting text does not require executing a bundle, so the gap between a fast laptop and a cheap phone shrinks for content — and grows for interactivity (The Real Cost of JavaScript).
- On a slow network, the early document is a real win, but only if the server answered quickly. A slow origin on a slow network is the worst combination in the module.
- Under a traffic spike, per-request rendering scales with visitors in a way a cached file does not, and the failure mode is an origin that falls over rather than a page that is stale (Static Site Generation).
- With a large dataset in view, the serialized state grows with the data and is paid twice: once as document bytes and once as a main-thread parse (List Virtualization).
- Early, indexable, assistive-technology-visible content, bought with per-request server cost, a first byte that waits on your data, and a caching story that personalization makes genuinely hard.
- The same bundle as before plus embedded state, so total bytes go up while the time to readable content goes down. Which of those a given route cares about is the actual decision.
- Two execution environments for one component tree, which is a permanent tax on every dependency you add and the source of an entire failure class (Hydration Mismatch).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALRunning the component tree on the server to produce markup, then hydrating it on the client, is the shape every framework with a server renderer uses, and the content-early / interactivity-late consequence follows from the shape rather than from any implementation.
- FRAMEWORK-SPECIFICHow the serialized state is embedded and named, whether hydration is a single pass or resumable, and which lifecycle hooks run on the server all differ per framework — React embeds a state blob and re-renders the tree, Svelte and Solid hydrate against the existing DOM with far less client work, and Qwik serializes the listeners instead so it can skip the pass entirely.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering: a server-rendered route needs an assertion on the raw response body — both that the content is there and that nothing server-only is — which no component test can make.