HomeGallerySketchesBlogAbout

BeyondClient-SideRendering

from CSR to Prerendering

by MohsenApril 2026

Welcome. This talk builds the mental model you need to answer three questions that keep coming up: Why is the page slow? Why can't crawlers see our SPA? Should we add SSR? We'll start from how browsers work and build up to modern rendering strategies.

Why Should We Care?

  • Slow initial loads (SPAs fan out into many JS chunks on cold load)
  • Search visibility on public pages (crawlers index prerendered HTML reliably)
  • Graceful degradation (content stays even if a JS chunk fails)
  • Accessibility (assistive tech sees real content on first paint)
  • Edge hosting (prerendered HTML runs on any CDN)
  • Not every project needs SSR, but every team should know when
These are real pain points you have probably hit before. The answers aren't obvious because SSR, prerendering, and CSR sit on a spectrum. One nuance worth calling out early, if your app is fully behind auth (a DiGA style app, an internal tool), crawler visibility matters less, but the UX cost of CSR is still paid by every user on every cold load. By the end of this talk you'll have a decision framework that applies in both cases.

Roadmap

  1. How Browsers Build a Page
  2. Client-Side Rendering (CSR)
  3. Key Performance Metrics
  4. Prerendering
  5. Hydration
  6. Event Replay (Angular)
  7. Choosing a Rendering Strategy
  8. Quick Reference
We start with the fundamentals, how browsers paint pages, and work up to modern patterns like partial prerendering. Concepts first, then Angular specifics.
1

How Browsers Build a Page

The foundation for understanding prerendering

The Critical Rendering Path

flowchart LR H["HTML\nbytes\narrive"] -->|"<span style='padding:2px 10px;display:inline-block;font-size:0.8em'>parse (main thread)</span>"| A["DOM\nTree"] H -.->|"<span style='padding:2px 10px;display:inline-block;font-size:0.8em'>link tags discovered</span>"| S["CSS\nbytes\nfetched"] S --> D["CSSOM\nTree"] A & D --> E["Render Tree\n(visible nodes)\n(awaits both)"] E -->|"<span style='padding:2px 10px;display:inline-block;font-size:0.8em'>layout</span>"| F["Positions\n+\nSizes"] F -->|"<span style='padding:2px 10px;display:inline-block;font-size:0.8em'>paint</span>"| G["Pixels\non\nScreen"] style A fill:#3fb950,stroke:#3fb950,color:#0d1117 style D fill:#3fb950,stroke:#3fb950,color:#0d1117 style E fill:#d29922,stroke:#d29922,color:#0d1117 style G fill:#58a6ff,stroke:#58a6ff,color:#fff
HTML parsing is the primary driver on the main thread. As soon as the parser encounters a `link rel="stylesheet"` it kicks off the CSS fetch, and the CSS parser runs in parallel (often off thread). The render tree can only combine once both DOM and CSSOM are ready. Understanding this matters because prerendering is fundamentally about giving the browser more to work with at the very first step, real HTML instead of an empty shell.

The Key Insight

  • HTML + CSS are all the browser needs (no JS required)
  • JavaScript is parser-blocking (a script tag halts HTML parsing)
  • defer / async unblock the parser (HTML streams during download)
  • But they don't fix CSR (nothing paints until the app bootstraps)
  • Many JS bundles compound the wait (every chunk gates first paint)
  • Prerendered HTML paints immediately (even on slow connections)
The common confusion here is thinking `defer` or `async` solve the CSR problem. They don't. They solve the narrow issue of a script tag halting the HTML parser. With CSR that barely matters because the HTML is an empty shell, there is nothing to paint either way. The deeper problem is that the browser cannot render real content until the framework has booted and rendered its component tree, and a modern SPA needs every one of its bundles before that happens. Prerendering sidesteps this entirely by shipping HTML that already contains the rendered tree.
2

Client-Side Rendering

Default SPA behavior

CSR in a Nutshell

  • Browser receives HTML from the server (empty shell)
<body>
  <app-root></app-root>
  <script src="main.abc123.js" defer></script>
</body>
This is what Angular, React, and Vue ship by default. An empty app-root and a script tag. The user stares at a blank page until JavaScript downloads and executes.

CSR in a Nutshell

  • Browser receives HTML from the server (empty shell)
  • JavaScript runs on the client (real FCP)
<body>
  <app-root>
    <nav>Home | Blog | About</nav>
    <main>
      <h1>Welcome back, Mohsen</h1>
      <p>You have 3 unread notifications.</p>
    </main>
  </app-root>
  <script src="main.abc123.js" defer></script>
</body>
After the JS bundle downloads, parses, and executes, Angular constructs the DOM inside app-root. Only now can the user see the page. On slow connections or low-end devices, this wait is 5-10+ seconds of blank screen.

What Actually Happens, Client-Side

flowchart LR A["HTML arrives"] --> B["JS download\n+\nparse"] --> C["Angular boots up"] --> D["Content is visible"] style A fill:#f85149,stroke:none,color:#fff style B fill:#f85149,stroke:none,color:#fff style C fill:#f85149,stroke:none,color:#fff style D fill:#3fb950,stroke:none,color:#0d1117
After the server's single response (the shell plus script URLs), everything above happens on the client's main thread. The wait time depends on bundle size, network speed, and device capability. The user stares at a blank page or spinner the entire time.

What the User Experiences

  • Blank screen or spinner (until the app bootstraps and renders)
  • Slow networks or weak devices make the wait painful (both compound on cold load)
  • Routing tech debt bloats first paint (heavy NgModules, poor lazy loading)
The wait is really about two compounding factors, bundle size against the user's network and device. A low-end phone on a 3G connection can spend 10+ seconds staring at a blank shell before the app bootstraps, parses, executes, and finishes its first render. Routing tech debt makes this worse by shipping too much JS for the initial route. The same class of debt also hurts hydration later, but that is a section 5 discussion, not a user experience point.
3

Key Performance Metrics

What we are optimising

FCP, LCP, and Interactivity

  • FCP (First Contentful Paint, first real content on screen)
  • LCP (Largest Contentful Paint, main content visible)
  • TTI (Time to Interactive, page is fully usable)
  • INP (Interaction to Next Paint, Core Web Vital for responsiveness)
  • LCP affects SEO (Core Web Vital, a known Google ranking signal)
  • Prerendering improves FCP and LCP (interactivity still waits for JS)
TTI has faded out as an official metric, Lighthouse 10 removed it in late 2023. INP took over from FID as the Core Web Vital for responsiveness in March 2024. The two measure slightly different things, TTI watched the main thread for quietness after FCP, INP watches real user interaction latency. We will keep "TTI" as shorthand in the next few diagrams because the audience intuition is the same, when can the user actually interact with the page. One caveat for paid licence or auth gated web apps, the SEO side of LCP drops out because nobody is crawling the app, but the UX side still matters, every cold load on a slow device pays the CSR paint cost.

From FCP to TTI

  • With Client-Side Rendering (CSR)
flowchart LR A1["HTML shell arrives"] --> A2["Blank screen\n(shell paint only)"] --> A3["JS loads\n+\napp bootstraps"] --> A4["FCP ≈ TTI"] style A1 fill:#484f58,stroke:none,color:#c9d1d9 style A2 fill:#f85149,stroke:none,color:#fff style A3 fill:#bc8cff,stroke:none,color:#0d1117 style A4 fill:#3fb950,stroke:none,color:#0d1117
  • Real content paint and TTI collapse into one moment (both wait on JS)
  • We use real content FCP here, strict Lighthouse FCP can fire earlier on a shell spinner

From FCP to TTI

  • With Prerendering
flowchart LR B1["HTML arrives"] --> B2["FCP"] --> B3["JS loading\n+\nHydration"] --> B4["TTI"] style B1 fill:#484f58,stroke:none,color:#c9d1d9 style B2 fill:#3fb950,stroke:none,color:#0d1117 style B3 fill:#d29922,stroke:none,color:#0d1117 style B4 fill:#3fb950,stroke:none,color:#0d1117
  • Content visible early, interactivity waits on hydration
This gap is SSR's main footgun. The page LOOKS done but event handlers haven't been attached yet. The user clicks and nothing happens. Good frameworks solve this with event replay, covered in section 6.

So far...

1: How Browsers Build Pages
Browsers can paint as soon as HTML + CSS arrive. JavaScript is not required to render.
2: Client-Side Rendering
CSR ships an empty shell. The DOM and app setup are JS-generated, so users wait for the full bundle to download, parse, and execute before seeing any content.
3: Key Performance Metrics
FCP and LCP mark when content appears, TTI marks when the page is interactive. With CSR all three collapse onto JS. Prerendering decouples them, content shows earlier while interactivity still waits for JS.
Natural pause before introducing the fix. Make sure you are comfortable with why CSR delays FCP, and what LCP is and why it matters for search. Good, now we solve it.
4

Prerendering

aka Static Site Generation (SSG) in SPA frameworks

How Prerendering Works

  1. A Node process starts the built frontend
  2. Navigates to each selected route for prerendering
  3. Captures the rendered HTML
  4. Writes static .html files
  5. Files deployed to CDN or self-hosted backend
  • Hybrid adoption (start small, grow gradually)
  • At request time, the server serves a pre-built file
  • As fast as serving an image, no server compute at all
Prerendering runs the built frontend at deploy time, captures the HTML for each selected route, and stores it as a static file. At runtime, the CDN or backend just serves the file, no rendering needed. Clean URLs like /about over /about/index.html are a small host config detail, nginx, Apache, S3, and CloudFront each have a standard way to handle it. Hybrid adoption matters here, most legacy CSR codebases cannot be prerendered wholesale, you pick a handful of simple routes first (login, landing, about) and expand as you fix the hard ones.

CSR vs Prerendering

CSRPrerendering (SSG)
HTML on arrivalEmpty shellFull content
FCPAfter JS executesImmediately
SEO, crawlersTwo pass render, less reliableIndexed as plain HTML
InfrastructureStatic file hostStatic file host
Works with dynamic data?Client-side fetchStatic HTML + client fetch
Critical path requestsHTML + all JS chunks + dataOne HTML response
Best forBrowser-API-heavy pagesMarketing, blog, docs
  • Gzip friendly (bigger HTML still smaller than the JS bundle it replaces)
  • Round trips collapse (one HTML replaces the CSR waterfall)
Both strategies can use the same static file host, S3, Netlify, or similar. The key difference is what the browser gets on first load. Anticipated question, "Isn't the prerendered HTML much bigger?" Yes, raw HTML is larger than an empty shell, but gzip/brotli compress it down to a few KB. The JS bundle that CSR needs before *anything* paints is hundreds of KB. HTML also streams, the browser starts painting before the file finishes downloading. JS doesn't have that property. The net effect on load time is overwhelmingly positive.
5

Hydration

Making prerendered pages interactive

What Hydration Does

  • Attaches event listeners
  • Sets up reactive state
  • Wires up bindings and references
  • Does NOT change the visible HTML (user sees nothing happen)
Good analogy, like moving into a furnished flat, the framework takes ownership of what is already there without tearing walls down. Hydration walks the existing DOM nodes, attaches handlers, and sets up state, all without repainting. The naive alternative would be to discard the HTML and re-render from scratch, causing a flash of blank content.

Hydration Process

sequenceDiagram autonumber participant S as Server participant B as Browser participant F as Framework S->>B: HTML with real content Note over B: Paints immediately ✅ B->>B: JS bundle downloads B->>F: Bundle loaded F->>B: Finds existing DOM nodes F->>B: Attaches event handlers Note over B: Button works ✅ (no DOM change)
The button is visible and looks correct from the moment HTML arrives, but it does not actually DO anything until hydration attaches the click handler. This is the FCP/TTI gap in action. Important mental model, Browser and Framework both live on the client's main thread, the framework runs as JS on the same thread the browser uses for rendering, which is why a heavy hydration pass can block paint if it is not incremental. Early clicks before step 7 are not necessarily lost, event replay (section 6) can capture and replay them once hydration completes.

Hydration Mismatches

  • Exact match required (prerendered HTML vs client render)
  • Some APIs can't prerender (different in Node, or unavailable)
SourceExampleWhy it breaks
Browser globalswindow, documentDon't exist in Node.js
Timestampsnew Date()Build time ≠ view time
RandomnessMath.random()Different value each run
Device infonavigator.userAgentUnavailable on server
Browser APIscanvas, WebGLRequire browser runtime
These all share a common trait, they produce different results at build time (Node.js) versus in the browser. The fix is always the same, guard browser only code.

Hydration Mismatch Fix

import { Component, ElementRef, ViewChild, afterNextRender } from '@angular/core';

@Component({ selector: 'app-chart', template: '<canvas #c></canvas>' })
export class ChartComponent {
  @ViewChild('c') canvas!: ElementRef<HTMLCanvasElement>;

  constructor() {
    afterNextRender(() => {
      // Safe here, browser only, runs AFTER hydration
      const ctx = this.canvas.nativeElement.getContext('2d')!;
      // (in production, keep the observer and disconnect it in ngOnDestroy)
      new ResizeObserver(() => this.redraw(ctx)).observe(ctx.canvas);
    });
  }

  private redraw(ctx: CanvasRenderingContext2D) { /* draw chart */ }
}
  • afterNextRender (Angular hook, fires in browser after hydration)
  • Skipped on the server, no platform check needed
  • Same idea, useEffect in React, onMounted in Vue
afterNextRender is an Angular lifecycle hook introduced in v16, it registers a callback that only fires in the browser, after the component's first render. On the server the callback is simply ignored, no platform check needed. isPlatformBrowser still works as an alternative but is more verbose. The key property, the callback fires AFTER hydration, so window, document, canvas, ResizeObserver etc. are all safe to use at that point. The chart example is typical, the canvas element is just HTML on the server, the drawing code runs only once hydration has wired up the ViewChild reference in the browser.

So far...

4: Prerendering (SSG)
Prerendering renders each route to a static HTML file at build time and deploys it to CDN or self-hosted backend. Full content on first byte, FCP moves dramatically earlier, no rendering at runtime.
5: Hydration
Hydration is the framework taking ownership of the prerendered DOM without repainting, attaching event handlers and wiring up state invisibly. The page looks ready, hydration makes it actually ready.
You now have the full picture of how prerendering and hydration fit together. The next slides are practical, when to prerender, what event replay does, and the simple decision rule.
6

Event Replay

Bridging the FCP to TTI gap in Angular

How It Works

  • Inline script captures early events (in the prerendered HTML)
  • Replays them once hydration completes
sequenceDiagram autonumber participant U as User participant R as Replay Script participant F as Framework Note over R: HTML painted ✅ U->>R: Clicks "Submit" early R-->>R: Event captured + queued Note over F: JS loads + hydrates R->>F: Replays captured events Note over F: Submit handler fires ✅
  • Without replay, early clicks are silently lost
This solves the uncanny valley problem from section 3. The user clicks a button that looks ready, and even though hydration hasn't happened yet, the click is captured and replayed once the framework takes over.

CSP Can Break Event Replay

  • Event replay uses an inline script that CSP blocks by default
  • If missed, the page looks loaded but clicks do nothing
  • Whitelist the SHA-256 hash in your CSP response header, like
Content-Security-Policy: script-src 'self' 'sha256-6qZOMt4EbT...='
  • Hash changes on Angular upgrades, so re-verify after each one
This is a real operational concern. If we upgrade Angular and forget to update the CSP hashes, the event replay script will be blocked silently. The page looks fully loaded, buttons, forms, everything painted, but nothing responds to clicks until hydration completes. This is the dead page problem from section 3, caused by a deployment oversight rather than a code bug.
7

Choosing a Rendering Strategy

Prerender or CSR?

Why is Page X Slow?

  • X can be any route currently rendered client-side (a CSR first paint)
  • What is actually slow, the JS bundle blocking paint
  • But the content is identical for every visitor
  • No dynamic data to fetch at render time (any CDN can serve it)
  • X could benefit from Prerendering
X stands for any route you ship client-side today, a login page is a common example, but any route with identical content for everyone fits. When a page feels slow, the instinct is to look at server capacity or caching, but the real cost is the JS bundle blocking first paint. If the content is identical for every visitor and no data is fetched at render time, prerendering is the right answer. This warms up the decision tree that follows.

The Rendering Strategy Decision Tree

flowchart TD A{"Content identical\nfor all users?"} A -->|"<span style='padding:2px 12px;display:inline-block'>YES</span>"| B{"Meaningful content\nto prerender?"} A -->|"<span style='padding:2px 12px;display:inline-block'>NO</span>"| F{"Public page\nwith SEO stakes?"} B -->|"<span style='padding:2px 12px;display:inline-block'>YES</span>"| E["Prerender"] B -->|"<span style='padding:2px 12px;display:inline-block'>NO</span>"| D["CSR"] F -->|"<span style='padding:2px 12px;display:inline-block'>YES</span>"| C["Consider full SSR\n(out of scope)"] F -->|"<span style='padding:2px 12px;display:inline-block'>NO</span>"| D style C fill:#6e7681,stroke:none,color:#fff style D fill:#bc8cff,stroke:none,color:#0d1117 style E fill:#3fb950,stroke:none,color:#0d1117
  • Apply per route (same app can use a combination of Prerender, CSR, and SSR)
Walk through this tree for each route. Most routes in a typical app land on prerender. Even a page with canvas or WebGL can be prerendered if it has meaningful surrounding HTML, the browser API parts simply initialize after hydration. If the content is personalized, the next question is whether the page needs SEO or fast first paint for an outside audience. A public dashboard does, an auth-gated admin panel does not, and CSR is a perfectly valid answer for the latter. Anticipated question, "what about content that changes more often than our deploy cadence?" Look into ISR (Incremental Static Regeneration), individual pages rebuild on demand without a full redeploy. Out of scope today but worth knowing the term.

Rule of Thumb

  • Prerender where it fits (default for public routes)
  • CSR is fine with disciplined routing and lazy loading
  • Poorly code-split (coarse-grained) SPA, prerendering often beats squeezing the CSR
  • Easy to verify, disable JavaScript in an E2E run and see if the route still reads right
Prerendering is free performance for routes where the content is identical for every visitor, same infrastructure, better HTML. CSR is not a failure mode, a well maintained Angular app with lazy loaded routes and modest per route bundles is genuinely fast on CSR once the shell paints. Where prerendering earns its keep most dramatically is on legacy codebases, think NgModules everywhere, heavy shared dependencies, eagerly bootstrapped state. In those projects, switching public routes to prerender is often a faster win than trying to shrink the bundle. The dim bullet is a cheap sanity check, if the page still looks right with JS disabled, prerendering is doing its job.
8

Quick Reference

Terminology

Terms Used Today

TermMeaning
CSRClient-Side Rendering, browser starts with empty shell
SSG, PrerenderingHTML built once, served as static files
HydrationFramework adopts prerendered DOM, no repaint
Event replayCaptures events before hydration, replays after
FCPFirst Contentful Paint, any content visible
LCPLargest Contentful Paint (Core Web Vital, SEO)
TTITime to Interactive (lab metric, removed in Lighthouse 10)
INPInteraction to Next Paint, replaced FID as the responsiveness Core Web Vital
SSRServer-Side Rendering, per request (out of scope)

Further Reading

ResourceWhat it covers
Rendering on the Web
Addy Osmani & Jason Miller
The canonical overview. Start here.
Core Web Vitals
web.dev
Google's performance metrics and thresholds.
Angular SSR Guide
angular.dev
SSR, prerendering, hydration, and event replay.
The Cost of JavaScript
Addy Osmani
Why bundle size matters beyond download time.
The "Rendering on the Web" article by Osmani and Miller is the single best resource on this topic. It covers everything we discussed today and more. If you read one thing, make it that article.

Key Takeaways

ConceptKey insight
CSRBlank screen until JS boots, SEO is slower
PrerenderingFull HTML on arrival, better FCP + SEO, same CDN
HydrationFramework adopts the prerendered DOM invisibly
Event replayEarly clicks captured and replayed after hydration
Rule of thumbDecide per route, prerender where it fits, CSR is fine with discipline
These four points are the core mental model. Prerendering is free performance on the same infrastructure, there is almost never a reason not to do it for static content.

Questions?

Open the floor for questions. Common follow ups. "How do we decide for our app?", walk through the decision tree with a real route from the project. "Doesn't prerendering make the HTML huge?", raw HTML is bigger than a CSR shell, but compresses to a few KB, and the JS bundle it replaces in the critical path is hundreds of KB. HTML streams incrementally (browser paints before the file finishes downloading), JS doesn't. Net effect on load time is overwhelmingly positive. "What about pages with tons of dynamic content?", prerender the static shell (nav, layout, headings), fetch dynamic data after hydration. The user sees structure immediately instead of a blank screen. "Do we need a CDN?", no, works with any server that can serve static files, nginx, Apache, S3, Netlify. A CDN helps with latency but is not a prerequisite. "What about SEO for our CSR pages?", modern Googlebot does execute JS, but it uses a two pass render, fetch HTML first, queue for rendering later, so indexing is slower and less reliable than serving HTML directly. Many other crawlers still do not execute JS at all. Prerendered HTML sidesteps all of that, the content is just there on first byte.
Last updated 1 minute ago.