BeyondClient-SideRendering
from CSR to Prerendering
by MohsenApril 2026
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
Roadmap
- How Browsers Build a Page
- Client-Side Rendering (CSR)
- Key Performance Metrics
- Prerendering
- Hydration
- Event Replay (Angular)
- Choosing a Rendering Strategy
- Quick Reference
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
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)
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>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>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
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)
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)
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
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.
4
Prerendering
aka Static Site Generation (SSG) in SPA frameworks
How Prerendering Works
- A Node process starts the built frontend
- Navigates to each selected route for prerendering
- Captures the rendered HTML
- Writes static
.htmlfiles - 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
CSR vs Prerendering
| CSR | Prerendering (SSG) | |
|---|---|---|
| HTML on arrival | Empty shell | Full content |
| FCP | After JS executes | Immediately |
| SEO, crawlers | Two pass render, less reliable | Indexed as plain HTML |
| Infrastructure | Static file host | Static file host |
| Works with dynamic data? | Client-side fetch | Static HTML + client fetch |
| Critical path requests | HTML + all JS chunks + data | One HTML response |
| Best for | Browser-API-heavy pages | Marketing, blog, docs |
- Gzip friendly (bigger HTML still smaller than the JS bundle it replaces)
- Round trips collapse (one HTML replaces the CSR waterfall)
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)
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)
Hydration Mismatches
- Exact match required (prerendered HTML vs client render)
- Some APIs can't prerender (different in Node, or unavailable)
| Source | Example | Why it breaks |
|---|---|---|
| Browser globals | window, document | Don't exist in Node.js |
| Timestamps | new Date() | Build time ≠view time |
| Randomness | Math.random() | Different value each run |
| Device info | navigator.userAgent | Unavailable on server |
| Browser APIs | canvas, WebGL | Require browser runtime |
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,
useEffectin React,onMountedin Vue
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.
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
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
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
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)
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
8
Quick Reference
Terminology
Terms Used Today
| Term | Meaning |
|---|---|
| CSR | Client-Side Rendering, browser starts with empty shell |
| SSG, Prerendering | HTML built once, served as static files |
| Hydration | Framework adopts prerendered DOM, no repaint |
| Event replay | Captures events before hydration, replays after |
| FCP | First Contentful Paint, any content visible |
| LCP | Largest Contentful Paint (Core Web Vital, SEO) |
| TTI | Time to Interactive (lab metric, removed in Lighthouse 10) |
| INP | Interaction to Next Paint, replaced FID as the responsiveness Core Web Vital |
| SSR | Server-Side Rendering, per request (out of scope) |
Further Reading
| Resource | What 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. |
Key Takeaways
| Concept | Key insight |
|---|---|
| CSR | Blank screen until JS boots, SEO is slower |
| Prerendering | Full HTML on arrival, better FCP + SEO, same CDN |
| Hydration | Framework adopts the prerendered DOM invisibly |
| Event replay | Early clicks captured and replayed after hydration |
| Rule of thumb | Decide per route, prerender where it fits, CSR is fine with discipline |