SSR (Server-Side Rendering)
Server-side rendering (SSR) refers to the practice of generating HTML content on the server and sending it to the client.
From MDN Web Docs
Server-side rendering (SSR) refers to the practice of generating HTML content on the server and sending it to the client. SSR is opposed to client-side rendering, where the client generates the HTML content using JavaScript. Both techniques are not mutually exclusive and can be used together in the same application.
A static site can be considered as SSR (and can be generated using SSR infrastructure), but there are nuanced differences. Content of a static site is generated at build time, not at request time. Static sites often do not need to be deployed on a server at all, and can be served from a CDN.
The SSR/CSR distinction is more meaningful for sites with dynamic content, for example live-updating or user-specific content. In these cases, for every request, the server generates the HTML content on-the-fly because it is unrealistic to pregenerate every possible page. The HTML file contains near-complete page content, and any JavaScript asset is only to enable interactivity.
Benefits of SSR include:
- Accessibility: the page is (somewhat) usable without JavaScript, for example if Internet is slow, the user has disabled JavaScript, or the browser is old and JavaScript fails to run. However, any interactivity or client-side logic will not work.
- Crawler-friendliness: search engines, social media crawlers, and other bots can easily read the content without needing to execute JavaScript. Note that major search engines are capable of executing JavaScript so pure CSR sites can still be indexed, but social media crawlers usually cannot.
- Performance: the server can know ahead-of-time what content is needed and can fetch all necessary data at once, compared to CSR where the client is often only aware of further dependencies when it renders the initial page, causing a waterfall of requests.
Both SSR and CSR have their performance tradeoffs, and a mix of SSR and CSR can be used to combine the benefits of both techniques. For example, the server can generate a page skeleton with empty placeholders, and the client can fetch additional data and update the page as needed.
Metrics Primer: FCP / LCP / TTI
The swim-lane diagram below marks FCP / LCP / TTI milestones. Knowing what each metric actually measures upfront makes the diagram much easier to read.
- FCP First Contentful Paint — first visual content painted
- The moment text, images, SVG, or non-empty canvas is first painted on the browser viewport. A background-color-only screen doesn't count. A Core Web Vitals secondary metric — under 1.8 s is considered good.
- LCP Largest Contentful Paint — largest content painted
- When the largest content element (hero image, big heading, etc.) is painted. A proxy for 'meaningful first paint' and a primary Core Web Vitals metric. Under 2.5 s is good; over 4 s needs work.
- TTI Time to Interactive — when interactivity starts
- The moment the page is fully interactive — all event listeners attached and the main thread free of Long Tasks for 5 seconds. Dropped from the Core Web Vitals primary set (replaced by INP) in 2024, but still important as the 'feels usable' moment for real users.
Rendering Lifecycle
The 17 phases of SSR across eight lanes. Server-side renderToString (04–06) → network transfer → Browser / Compositor / GPU / Display pipeline produces pixels immediately (12). FCP / LCP land at that pixel output. Then hydration (15–16) runs and reaches TTI — a clear two-stage structure.
-
01 URL entry / link click
The user types a URL or clicks a link on an existing page.
-
02 Issue HTTP request
The browser prepares the request, including auth cookies and any conditional GET headers.
-
03 DNS / TCP / TLS
DNS resolution → TCP handshake → TLS handshake run in sequence at the network layer.
-
04 renderToString() starts
On Node.js, React / Vue / Svelte calls
renderToString(). Render functions begin executing and the component tree starts to form. -
05 DB / API access
During render,
getServerSideProps/loader/ Server Component fetches fire and pull data from DBs or external APIs. This time directly dominates TTFB. -
06 Emit HTML + embedded state
With data in hand, the final HTML string is assembled, and hydration state (
__NEXT_DATA__/window.__NUXT__) is embedded in a<script>tag. This is the 'finished HTML + reconstruction data' handed to the client. -
07 Transfer complete HTML
The server returns the complete HTML as an HTTP response. In streaming SSR, chunks begin to flow here.
-
08 Parse HTML → DOM (complete)
The browser parses the incoming HTML into a DOM. Unlike a SPA, this tree is already complete with real content.
-
09 Style / Layout / Paint-record (with content)
Combine CSSOM with DOM, then run style calculation → layout → paint-record. Since the DOM is already packed with real content, the generated paint commands are rich. The decisive difference vs. SPA's first pass.
-
10 Raster + layer composite
The compositor thread splits paint records into tiles, rasterizes them, and builds composite commands from the layer tree.
-
11 GPU builds the frame
The GPU process runs the composite commands and writes the assembled frame into the frame buffer.
-
12 Content appears on screen
Scanned out to the display in sync with vsync. This is when pixels reach the user. SSR's core value: unlike SPA, it's not 'first frame = empty, then content on the second frame' but rather 'content appears on the very first frame'. But JS isn't running yet, so interaction is still not possible.
- FCP / LCP reached — but not yet interactive
-
13 JS bundle transfer (parallel)
In parallel (or just after) the HTML, the JS bundle downloads. SSR does not eliminate JS — hydration requires the same component code on the client.
-
14 JS parse / compile
The JS engine (V8, etc.) parses the source and compiles to bytecode. Main-thread blocking time starts here.
-
15 hydrateRoot() → rebuild VDOM
In React,
hydrateRoot()runs. It rebuilds the virtual DOM and walks it node-by-node against the existing real DOM (reconciliation). If markup matches, the DOM is not re-created. -
16 Attach listeners / initialize state
Each component gets its event listeners attached; useState / stores initialize; useEffect / onMounted fire afterwards. Because hydration is designed to reuse the existing DOM, the Browser → Compositor → GPU → Display pipeline generally does not need to run again (subtrees with hydration mismatches are re-rendered and repainted as an exception).
- TTI reached — end of the 'visible but unresponsive' uncanny valley
-
17 Afterwards: SPA-style behavior
After hydration, navigation and state changes follow the SPA model. Each state change walks JS Engine → DOM patch → Browser → Compositor → GPU → Display, but network trips are only for API data.
What Hydration Actually Does
Hydration does not rebuild the DOM — it reuses the existing DOM and retrofits the JavaScript behavior onto it. Looking at what changes vs. what doesn't, item by item, makes this click.
| Item | Before hydration | After hydration |
|---|---|---|
| DOM | The server-rendered DOM is already in place | Hydration reuses it; any mismatch triggers a warning and re-render |
| Event listeners | Zero — clicks do nothing | Attached to each component |
| state / props | Not initialized — only the baked-in server values exist | Re-initialized by the client runtime |
| useEffect / onMounted | Not yet run | Fire after hydration completes |
| Browser APIs (window / localStorage) | During SSR the server has no window — inaccessible | After hydration they're safely available |
The core principle that follows: hydration requires the server and client to produce identical markup. Breaking this invariant triggers warnings and subtree re-renders (hydration mismatch).
Frequently Asked Questions
The questions that inevitably come up while learning SSR — and the root cause behind each.
Q. What does 'hydration' actually do?
Q. Why is FCP fast but TTI slow?
Q. Why does using `window` or `localStorage` crash on the server?
window or document — they are browser-only. Referencing them inside renderToString() throws ReferenceError: window is not defined. Fix with (a) a typeof window !== 'undefined' guard, (b) moving the code into useEffect / onMounted (client-only), or (c) dynamic imports with { ssr: false } so the module skips server rendering.Q. The hydration mismatch warning won't go away. Why?
Date.now() / Math.random() in render, (b) branching on user agent, (c) time-zone-dependent display, (d) libraries emitting different markup on SSR vs. client. The general fix: move non-deterministic code into useEffect and keep the first render stable. If that doesn't help, read the warning's diff to pinpoint which node disagrees.Q. How can SSR leak one user's data to another?
renderToString() runs per request, but the Node.js process is reused — stashing auth info in globals or module-scoped variables leaks it to the next request's user. Fix: (a) pass auth through function arguments (context / request), (b) initialize React Context / Vue provide/inject inside the render function, (c) never use global state. The safest path is to use the framework's built-in helpers: Next.js's cookies(), Nuxt's useRequestHeaders(), etc.Q. How is streaming SSR different from regular SSR?
<Suspense> docs describe, slow components inside a Suspense boundary show a fallback while their data resolves, and are replaced with real content once ready. React 18's renderToPipeableStream is the server-side entry point. Tradeoff: the backend must support streaming HTTP infrastructure.Design Signals
When to reach for SSR — and when not to.
Authenticated, per-user, and SEO-required
You need per-user content that crawlers should also see (e.g., personalized product listings, user-specific dashboards). SSG can't do it, and a CSR-only SPA is weak for SEO — SSR is the right tool.
Content updates too often to rebuild
Real-time data like inventory, pricing, or comments needs to be fresh every request. ISR on SSG can approximate this, but when second-level freshness matters SSR is simpler.
Mostly static, rarely updated
Marketing sites, docs, blogs, corporate sites. Choosing SSR here only adds server cost and operational complexity. SSG is sufficient.
Minimize server cost / traffic is unpredictable
SSR cost scales with requests × render time. For startups with unpredictable traffic, SSG or Edge Functions give more predictable economics. Partial adoption via Edge SSR (Vercel / Cloudflare) is a middle ground.
Common Pitfalls
Hydration mismatch
Server and client DOMs disagree. Classic causes: using Date.now() in render, UA branching, useLayoutEffect side effects. React's hydrateRoot troubleshooting docs describe a two-pass rendering pattern for intentional client/server differences: keep a state variable like isClient and set it to true inside an effect — i.e., apply non-deterministic values via the effect. suppressHydrationWarning offers targeted suppression, but the documented first step is to identify the source and split it into effect-driven rendering.
Leaking auth via globals
Node.js processes are reused across requests, so storing user info in globals or singletons leaks it to other users. Always pass data through a request-scoped context object.
CDN caching vs. personal data
Caching SSR responses with Cache-Control: public can serve user A's HTML to user B. Pages with personal data need private / no-store; user-specific fragments should be fetched client-side (CSR) separately.
Silent TTFB regressions
Heavy queries or N+1s inside renderToString quietly wreck TTFB, and because FCP / LCP don't surface it directly, regressions sneak in. Fix: measure per-route render time via APM (Datadog / New Relic) and isolate slow subtrees with streaming SSR or <Suspense>.