Angular SSR and Incremental Hydration: Server-Side Rendering in 2025
Server-Side Rendering (SSR) in Angular has undergone a radical transformation. From Angular Universal as an external, experimental package, SSR is now a first-class feature integrated into the core of the framework. With Angular 19+, the introduction of Incremental Hydration changed the game entirely: you no longer need to hydrate the entire application at once, but only the parts the user actually interacts with.
In this sixth article of the Modern Angular series, we will explore Angular's SSR architecture from basic configuration to advanced incremental hydration strategies, event replay, static prerendering, and SEO optimization. We will see how these technologies concretely impact Core Web Vitals and how to configure an Angular application for maximum performance.
What You Will Learn
- How Server-Side Rendering works in Angular and its evolution from Angular Universal
- The Full Application Hydration mechanism: DOM reuse and event replay
- The limitations of Full Hydration and its impact on Time to Interactive (TTI)
- Incremental Hydration with
@defertriggers: viewport, interaction, idle, timer - Complete SSR configuration:
app.config.server.ts, Express server, providers - Event Replay and jsaction integration for capturing interactions during hydration
- Prerendering (SSG) for static page generation
- SEO optimization with meta tags, structured data, and crawler rendering
- Practical patterns:
isPlatformBrowser,TransferState, browser-only APIs - Performance strategies: partial hydration, bundle optimization, Core Web Vitals
Modern Angular Series
| # | Article | Topic |
|---|---|---|
| 1 | Angular Signals: From Zero to Production | Signals API |
| 2 | Zoneless Change Detection: Goodbye Zone.js | Change detection |
| 3 | New Template Syntax: @if, @for, @defer | Control flow blocks |
| 4 | Standalone Components | Module-free architecture |
| 5 | Signal Forms | Reactive forms with Signals |
| 6 | You are here - SSR and Incremental Hydration | Server-side rendering |
| 7 | Core Web Vitals in Angular | Performance optimization |
| 8 | Angular PWA | Progressive Web Apps |
| 9 | Advanced Dependency Injection | DI patterns and tokens |
| 10 | Migrating from Angular 17 to 21 | Step-by-step migration |
1. Server-Side Rendering in Angular
Server-Side Rendering consists of generating a page's HTML on the server rather than in the browser. When a user or search crawler requests a page, the server executes the Angular application, produces complete HTML, and sends it to the client. The browser displays the content immediately without waiting for JavaScript to download and execute.
The Evolution: From Angular Universal to Core
Historically, SSR in Angular was handled by Angular Universal, a separate
package (@nguniversal/express-engine) that required significant manual
configuration. Starting with Angular 17, SSR was integrated directly into the framework:
@angular/ssr replaced Angular Universal and the CLI automatically generates
the necessary configuration files.
SSR Evolution in Angular
| Version | SSR Technology | Characteristics |
|---|---|---|
| Angular 2-16 | Angular Universal (@nguniversal) | External package, manual configuration, no hydration |
| Angular 16 | Angular Universal + Hydration (Developer Preview) | First non-destructive hydration, DOM reuse |
| Angular 17 | @angular/ssr (new package) | SSR integrated into CLI, stable hydration, @defer support |
| Angular 19 | @angular/ssr + Incremental Hydration | Incremental hydration, event replay, route-level rendering |
| Angular 21 | @angular/ssr (stable) | Stable incremental hydration, improved SSG, optimized performance |
How the SSR Flow Works
The lifecycle of an SSR request in Angular follows a precise flow involving the server, browser, and the hydration process.
- HTTP Request: The browser (or a crawler) requests a URL
- Server-side rendering: Express receives the request, Angular runs the app and produces HTML
- HTML delivery: The server sends the complete HTML with rendered content
- First Contentful Paint: The browser immediately displays the content
- JS Download: The browser downloads the application's JavaScript bundles
- Hydration: Angular "reanimates" the static HTML, attaching event listeners and reactivity
- Interactive application: The app becomes fully functional
# New project with SSR enabled
ng new my-ssr-app --ssr
# Add SSR to an existing project
ng add @angular/ssr
# The command automatically generates:
# - server.ts (Express server)
# - src/app/app.config.server.ts (server configuration)
# - Updates angular.json with "server" target
2. Full Application Hydration
Hydration is the process through which Angular transforms the static HTML generated by the server into an interactive application in the browser. Before Angular 16, the framework simply destroyed the server's HTML and re-rendered everything from scratch in the browser (destructive hydration). This caused an annoying content "flash" and negated much of SSR's benefits.
Non-Destructive Hydration
Starting from Angular 16, hydration became non-destructive: Angular reuses the DOM nodes generated by the server instead of recreating them. This eliminates the visual flash and significantly improves Largest Contentful Paint (LCP) because the browser does not need to redo the layout.
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
// Enable non-destructive hydration
provideClientHydration(),
]
};
How DOM Reuse Works
During server-side rendering, Angular serializes structural information into the HTML
through special attributes like ngSkipHydration and hidden comments. When the
client receives the HTML and starts hydration, the framework:
- Traverses the existing DOM instead of creating a new one
- Attaches event listeners to the already-present DOM nodes
- Initializes the internal state of components
- Verifies that the server DOM matches what the client would produce
Hydration Mismatch: Common Errors
If the HTML generated by the server does not match what the client expects, Angular throws a hydration mismatch error. The most common causes are:
- Accessing
window,document, orlocalStorageduring server rendering - Using
Date.now()orMath.random()which produce different values on server and client - Direct DOM manipulation with
ElementRef.nativeElement - Conditional content based on browser-only features (e.g., viewport size)
- Third-party libraries that directly modify the DOM
3. The Problem with Full Hydration
With Full Application Hydration, Angular must hydrate the entire application before it becomes interactive. This means that even components the user will never see (like the footer at the bottom of the page or hidden sections) are hydrated on load, directly impacting Time to Interactive (TTI).
Full Hydration Impact on Core Web Vitals
| Metric | No SSR (CSR) | SSR + Full Hydration | Impact |
|---|---|---|---|
| First Contentful Paint (FCP) | 2.5s | 0.8s | Significantly improved |
| Largest Contentful Paint (LCP) | 3.2s | 1.2s | Significantly improved |
| Time to Interactive (TTI) | 3.5s | 3.0s | Marginal improvement: entire app must hydrate |
| Total Blocking Time (TBT) | 800ms | 650ms | Still high: hydration is a long task |
| Interaction to Next Paint (INP) | 120ms | 180ms | Worsens: events lost during hydration |
The paradox of Full Hydration is that it improves initial rendering metrics (FCP, LCP) but does not solve, and sometimes worsens, interactivity metrics. The reason is straightforward: hydrating hundreds of components is a CPU-intensive operation that blocks the main thread.
Why Full Hydration Is Expensive
- Downloading all JavaScript: Even for components never visible, the browser must download and parse the code
- Executing every component: Angular must initialize each component, create the dependency injection tree, and connect bindings
- Main thread blocking: Hydration happens synchronously, blocking user interactions
- Wasted resources on mobile: Devices with limited CPU and bandwidth suffer the most
4. Incremental Hydration
Incremental Hydration solves the Full Hydration problem by allowing
components to be hydrated on demand, only when needed. Introduced as a developer
preview in Angular 19 and stabilized in Angular 21, this feature builds on the
@defer block already present in Angular's template syntax.
How It Works
The concept is simple but powerful: components wrapped in a @defer block are
rendered normally on the server (the user sees the content), but on the client their
hydration is deferred until a specific trigger fires. In the meantime,
the static server DOM remains visible but not interactive.
<!-- This section is rendered on the server but hydrated
only when it enters the user's viewport -->
@defer (hydrate on viewport) {
<app-product-list [category]="selectedCategory" />
}
<!-- This component is hydrated when the user
interacts with it (click, focus, hover) -->
@defer (hydrate on interaction) {
<app-comment-section [postId]="post.id" />
}
<!-- This is hydrated when the browser is idle,
meaning it has no other tasks to execute -->
@defer (hydrate on idle) {
<app-sidebar-recommendations />
}
<!-- This is hydrated after a specific timer -->
@defer (hydrate on timer(3s)) {
<app-analytics-widget />
}
<!-- Never hydrated: remains static HTML forever -->
@defer (hydrate never) {
<app-static-footer />
}
<!-- Hydrated immediately (equivalent to full hydration) -->
@defer (hydrate on immediate) {
<app-critical-cta />
}
Available Hydration Triggers
| Trigger | Syntax | When It Fires | Use Case |
|---|---|---|---|
| Viewport | hydrate on viewport |
Component enters the visible viewport | Below-the-fold content, product lists |
| Interaction | hydrate on interaction |
Click, focus, or touch on the element | Forms, buttons, interactive areas |
| Idle | hydrate on idle |
Browser has no tasks in queue | Secondary widgets, sidebars |
| Timer | hydrate on timer(Ns) |
After N seconds from page load | Analytics, tracking, deferred widgets |
| Immediate | hydrate on immediate |
Immediately on load | Critical above-the-fold components |
| Never | hydrate never |
Never: remains static HTML | Footer, purely informational content |
Enabling Incremental Hydration
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import {
provideClientHydration,
withIncrementalHydration
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// Enable hydration with incremental support
provideClientHydration(
withIncrementalHydration()
),
]
};
Requirements for Incremental Hydration
- Deferred components must be standalone (they cannot depend on NgModule)
- The
withIncrementalHydration()function requiresprovideClientHydration()as well - Content inside
@deferis still rendered on the server: the trigger only controls when it gets hydrated on the client - The
@placeholderand@loadingblocks of@deferare not used in SSR: the server directly shows the full content
5. Complete SSR Configuration
A production-ready SSR configuration in Angular involves several files. Let us examine the complete structure and the role of each component.
Server-Side Configuration
// src/app/app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRoutesConfig } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
// Enable server-side rendering
provideServerRendering(),
// Route-level rendering configuration
provideServerRoutesConfig(serverRoutes),
]
};
// Merge client config with server config
export const config = mergeApplicationConfig(appConfig, serverConfig);
Route-Level Rendering Configuration
// src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '',
renderMode: RenderMode.Prerender, // SSG: generated at build time
},
{
path: 'blog',
renderMode: RenderMode.Prerender, // Static page
},
{
path: 'blog/:slug',
renderMode: RenderMode.Server, // SSR: generated per request
},
{
path: 'dashboard',
renderMode: RenderMode.Client, // CSR: client-side only
},
{
path: '**',
renderMode: RenderMode.Server, // Default: SSR for everything else
},
];
Express Server
// server.ts
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node';
import express from 'express';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import bootstrap from './src/main.server';
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine();
// Serve static files from the browser folder
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y', // Aggressive caching for static assets
index: false,
}));
// All other routes go through Angular SSR
server.get('**', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [
{ provide: APP_BASE_HREF, useValue: baseUrl }
],
})
.then(html => res.send(html))
.catch(err => next(err));
});
return server;
}
function run(): void {
const port = process.env['PORT'] || 4000;
const server = app();
server.listen(port, () => {
console.log(`SSR server listening on http://localhost:${port}`);
});
}
run();
SSR File Structure
| File | Role |
|---|---|
src/app/app.config.ts |
Client configuration: router, hydration, providers |
src/app/app.config.server.ts |
Server configuration: provideServerRendering, serverRoutes |
src/app/app.routes.server.ts |
Defines renderMode per route (Prerender, Server, Client) |
server.ts |
Express server that handles SSR requests |
src/main.server.ts |
Entry point for server-side bootstrap |
6. Event Replay
One of the most frustrating problems with traditional SSR is the so-called "uncanny valley": the user sees the rendered page and starts interacting (clicking buttons, filling forms), but the application does not respond because hydration has not yet completed. Events are lost and the user must repeat their actions.
How Event Replay Works
Angular solves this problem with Event Replay, based on Google's
jsaction library. The mechanism works in three phases:
- Capture: A small inline script (injected into the HTML by the server) captures all user events before Angular is ready
- Buffer: Events are stored in a queue with all necessary information (event type, target, timestamp)
- Replay: When the component's hydration is complete, events are re-dispatched in the correct order
// app.config.ts
import {
provideClientHydration,
withEventReplay,
withIncrementalHydration
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
// Event Replay: capture events during hydration
withEventReplay(),
// Incremental Hydration: hydrate on demand
withIncrementalHydration(),
),
]
};
Event Replay: Behavior with Incremental Hydration
With Incremental Hydration, Event Replay becomes even more important. When a user clicks
on a component whose hydration is deferred (e.g., hydrate on interaction),
the following happens:
- The event is captured by jsaction
- The click trigger activates the component's hydration
- Angular downloads the lazy code for the component and hydrates it
- The captured event is re-dispatched on the now-interactive component
- The user perceives no significant delay
7. Prerendering (SSG)
Prerendering (or Static Site Generation, SSG) generates page HTML at build time rather than on each request. Pages are saved as static HTML files and served directly from a CDN or web server without requiring a running Node.js server.
When to Use SSG vs SSR
SSG vs SSR: Decision Guide
| Criterion | SSG (Prerender) | SSR (Server) |
|---|---|---|
| Content | Static, rarely changes | Dynamic, personalized per user |
| Performance | Extremely fast (files served from CDN) | Depends on server rendering time |
| Infrastructure | Static hosting only (Firebase, Netlify, S3) | Running Node.js server required |
| Scalability | Excellent (CDN handles the load) | Requires server scaling |
| Updates | Requires rebuild and deploy | Content always up to date |
| Typical use cases | Blogs, landing pages, documentation, portfolios | E-commerce, dashboards, social media |
Configuring Prerendering
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// Static pages: generated at build time
{
path: '',
renderMode: RenderMode.Prerender,
},
{
path: 'about',
renderMode: RenderMode.Prerender,
},
// Parametric routes: explicit list of paths
{
path: 'blog/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
// Returns an array of objects with parameters
return [
{ slug: 'angular-ssr-hydration' },
{ slug: 'angular-signals-guide' },
{ slug: 'zoneless-change-detection' },
];
},
},
// Dynamic routes: SSR at runtime
{
path: 'product/:id',
renderMode: RenderMode.Server,
},
// Dashboard: client-side only
{
path: 'admin/**',
renderMode: RenderMode.Client,
},
];
RenderMode: The Three Strategies
- RenderMode.Prerender (SSG): HTML generated at build time, ideal for static content. Extremely fast, no server needed at runtime
- RenderMode.Server (SSR): HTML generated per request on the server. Required for personalized or frequently changing content
- RenderMode.Client (CSR): No server rendering, HTML generated entirely in the browser. Useful for protected areas or interactive dashboards
8. SEO with SSR
SSR is fundamental for SEO because search engine crawlers can read the server-generated HTML directly without having to execute JavaScript. But rendering alone is not enough: you need structured meta tags, JSON-LD data, and proper canonical URL handling.
Dynamic Meta Tags with Title and Meta Service
// seo.service.ts
import { Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { DOCUMENT } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class SeoService {
private title = inject(Title);
private meta = inject(Meta);
private document = inject(DOCUMENT);
updatePageMeta(config: {
title: string;
description: string;
url: string;
image?: string;
type?: string;
}): void {
// Title tag
this.title.setTitle(config.title);
// Meta description
this.meta.updateTag({
name: 'description',
content: config.description
});
// Open Graph tags
this.meta.updateTag({ property: 'og:title', content: config.title });
this.meta.updateTag({ property: 'og:description', content: config.description });
this.meta.updateTag({ property: 'og:url', content: config.url });
this.meta.updateTag({ property: 'og:type', content: config.type || 'website' });
if (config.image) {
this.meta.updateTag({ property: 'og:image', content: config.image });
}
// Twitter Card
this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
this.meta.updateTag({ name: 'twitter:title', content: config.title });
this.meta.updateTag({ name: 'twitter:description', content: config.description });
// Canonical URL
this.setCanonicalUrl(config.url);
}
addStructuredData(data: object): void {
const script = this.document.createElement('script');
script.type = 'application/ld+json';
script.textContent = JSON.stringify(data);
this.document.head.appendChild(script);
}
private setCanonicalUrl(url: string): void {
let link: HTMLLinkElement | null =
this.document.querySelector('link[rel="canonical"]');
if (!link) {
link = this.document.createElement('link');
link.setAttribute('rel', 'canonical');
this.document.head.appendChild(link);
}
link.setAttribute('href', url);
}
}
Structured Data (JSON-LD)
// In a blog article component
@Component({ /* ... */ })
export class ArticlePageComponent {
private seo = inject(SeoService);
ngOnInit(): void {
const article = this.articleData();
this.seo.updatePageMeta({
title: `${article.title} | Federico Calo`,
description: article.summary,
url: `https://federicocalo.dev/blog/${article.slug}`,
image: 'https://federicocalo.dev/assets/og-image.jpg',
type: 'article',
});
// Structured Data for Google
this.seo.addStructuredData({
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.summary,
author: {
'@type': 'Person',
name: 'Federico Calo',
url: 'https://federicocalo.dev',
},
datePublished: article.date,
image: 'https://federicocalo.dev/assets/og-image.jpg',
publisher: {
'@type': 'Organization',
name: 'Federico Calo Dev',
url: 'https://federicocalo.dev',
},
});
}
}
SEO Checklist for Angular SSR Applications
- Unique title tag per page: Maximum 60 characters, primary keyword included
- Meta description: 150-160 characters, compelling summary of the page
- Open Graph and Twitter Card: For proper social media previews
- Canonical URL: Prevents duplicate content issues
- JSON-LD structured data: Article, Person, Organization, BreadcrumbList
- Sitemap.xml: Auto-generated with hreflang for multilingual sites
- robots.txt: Configured to allow crawling of important pages
- Heading hierarchy: Single H1 per page, H2-H6 in logical order
9. Practical Patterns for SSR
Working with SSR requires attention to the differences between server and browser
environments. Many browser APIs (such as window, localStorage,
document.querySelector) are not available during server-side rendering.
Here are the essential patterns for handling these situations.
isPlatformBrowser and isPlatformServer
import { Component, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
@Component({
selector: 'app-analytics',
standalone: true,
template: `<div #trackingContainer></div>`
})
export class AnalyticsComponent {
private platformId = inject(PLATFORM_ID);
ngOnInit(): void {
if (isPlatformBrowser(this.platformId)) {
// Safe: executed only in the browser
this.initGoogleAnalytics();
this.trackPageView();
}
if (isPlatformServer(this.platformId)) {
// Executed only on the server
console.log('Server-side rendering for:', this.route);
}
}
private initGoogleAnalytics(): void {
// Safe access to window and document
const gtag = (window as any).gtag;
if (gtag) {
gtag('config', 'G-XXXXXXXXXX');
}
}
private trackPageView(): void {
// localStorage only available in the browser
const views = localStorage.getItem('pageViews') || '0';
localStorage.setItem('pageViews', String(Number(views) + 1));
}
}
TransferState: Avoiding Duplicate HTTP Requests
When the server makes an API request to render a page, the client should not repeat the same request. TransferState allows you to transfer data from server to client by embedding it in the HTML.
import {
Injectable, inject, PLATFORM_ID,
TransferState, makeStateKey
} from '@angular/core';
import { isPlatformServer } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
interface Product {
id: number;
name: string;
price: number;
}
const PRODUCTS_KEY = makeStateKey<Product[]>('products');
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private transferState = inject(TransferState);
private platformId = inject(PLATFORM_ID);
getProducts(): Observable<Product[]> {
// 1. Check if data is already in TransferState
const cached = this.transferState.get(PRODUCTS_KEY, null);
if (cached) {
// Data transferred from server: use it and remove it
this.transferState.remove(PRODUCTS_KEY);
return of(cached);
}
// 2. No cached data: make the HTTP request
return this.http.get<Product[]>('/api/products').pipe(
tap(products => {
if (isPlatformServer(this.platformId)) {
// 3. On server: save to TransferState
this.transferState.set(PRODUCTS_KEY, products);
}
})
);
}
}
HttpClient and Automatic TransferState
Starting from Angular 18, provideClientHydration() automatically includes
HTTP request caching via withHttpTransferCacheOptions(). This means that in
most cases you do not need to manually manage TransferState for HTTP calls: Angular handles
it for you.
afterNextRender and afterRender
For code that must only execute in the browser after rendering, Angular provides two dedicated APIs that are SSR-safe and zoneless-compatible.
import { Component, afterNextRender, afterRender, ElementRef, viewChild } from '@angular/core';
@Component({
selector: 'app-chart',
standalone: true,
template: `<canvas #chartCanvas width="800" height="400"></canvas>`
})
export class ChartComponent {
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('chartCanvas');
constructor() {
// Executed ONCE after the first render in the browser
// NOT executed on the server
afterNextRender(() => {
const ctx = this.canvas().nativeElement.getContext('2d');
this.initChart(ctx!);
});
// Executed EVERY TIME Angular renders the component
// Useful for syncing with external DOM libraries
afterRender(() => {
this.updateChartDimensions();
});
}
private initChart(ctx: CanvasRenderingContext2D): void {
// Initialize a chart with Canvas API
// Safe: executed only in the browser
}
private updateChartDimensions(): void {
// Update dimensions based on viewport
}
}
10. Performance Optimization
SSR and Incremental Hydration have a direct impact on Core Web Vitals. Let us explore concrete strategies to maximize performance.
Hydration Strategy by Content Type
Hydration Strategy Map
| Page Zone | Strategy | Rationale |
|---|---|---|
| Navbar and Hero section | hydrate on immediate |
Above-the-fold, immediate interaction needed |
| Main content (article, product) | Full hydration (no @defer) | Core content, likely LCP element |
| Comments section | hydrate on viewport |
Below-the-fold, visible only with scrolling |
| Contact form | hydrate on interaction |
Interactive but not critical on first load |
| Recommendations sidebar | hydrate on idle |
Secondary, hydrate when browser is free |
| Analytics/tracking widgets | hydrate on timer(5s) |
Not visible, can wait |
| Footer and legal links | hydrate never |
Purely informational, no interactivity needed |
Core Web Vitals Impact
Benchmark: Blog Application with 50+ Articles
| Metric | CSR Only | SSR + Full Hydration | SSR + Incremental Hydration |
|---|---|---|---|
| FCP | 2.4s | 0.8s | 0.7s |
| LCP | 3.1s | 1.1s | 0.9s |
| TTI | 3.8s | 3.2s | 1.5s |
| TBT | 850ms | 620ms | 180ms |
| INP | 110ms | 170ms | 65ms |
| CLS | 0.15 | 0.02 | 0.01 |
| Initial JS | 320KB | 320KB | 85KB |
Bundle Optimization with Incremental Hydration
<!-- Product page layout optimized for Core Web Vitals -->
<!-- CRITICAL: immediate hydration (above-the-fold) -->
<app-navbar />
<app-product-hero [product]="product()" />
<!-- VISIBILITY: hydrate on viewport -->
@defer (hydrate on viewport) {
<app-product-details [product]="product()" />
}
@defer (hydrate on viewport) {
<app-product-reviews [productId]="product().id" />
}
<!-- INTERACTION: hydrate on user input -->
@defer (hydrate on interaction) {
<app-add-to-cart [product]="product()" />
}
<!-- BACKGROUND: hydrate when browser is free -->
@defer (hydrate on idle) {
<app-related-products [categoryId]="product().categoryId" />
}
<!-- DEFERRED: hydrate after timer -->
@defer (hydrate on timer(5s)) {
<app-user-tracking [productId]="product().id" />
}
<!-- STATIC: never hydrated -->
@defer (hydrate never) {
<app-footer />
}
Measuring the Impact
// performance-monitor.service.ts
import { Injectable, inject, PLATFORM_ID, afterNextRender } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class PerformanceMonitorService {
private platformId = inject(PLATFORM_ID);
constructor() {
afterNextRender(() => {
this.observeWebVitals();
});
}
private observeWebVitals(): void {
if (!isPlatformBrowser(this.platformId)) return;
// Largest Contentful Paint
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime, 'ms');
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// First Input Delay
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries() as PerformanceEventTiming[];
entries.forEach(entry => {
console.log('FID:', entry.processingStart - entry.startTime, 'ms');
});
});
fidObserver.observe({ type: 'first-input', buffered: true });
// Cumulative Layout Shift
const clsObserver = new PerformanceObserver((list) => {
let clsScore = 0;
list.getEntries().forEach((entry: any) => {
if (!entry.hadRecentInput) {
clsScore += entry.value;
}
});
console.log('CLS:', clsScore);
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
}
}
Common Mistakes to Avoid with SSR
- Accessing
windowwithout guards: Always useisPlatformBrowserorafterNextRender - Hydration mismatch: Do not generate different content between server and client (e.g.,
Date.now(), random data) - Importing browser-only libraries: Libraries like Chart.js or Leaflet should be loaded with dynamic imports in the browser
- Not using TransferState: Without TransferState (or Angular 18+ auto-caching), HTTP calls are made twice
- Hydrating everything: With Incremental Hydration available, hydrating the entire app is wasteful. Use
hydrate neverfor static content - Ignoring CLS: Web fonts, images without dimensions, and dynamically loaded content cause layout shifts
Summary and Next Steps
SSR in Angular has taken an enormous qualitative leap: from Angular Universal as an external package to an integrated, performant, and flexible solution. Incremental Hydration represents the true revolution, allowing you to combine the best possible initial rendering with immediate, targeted interactivity.
Key Takeaways from This Article
- Angular SSR: Integrated into the core with
@angular/ssr, replacing Angular Universal - Full Hydration: Reuses server DOM but hydrates everything at once, impacting TTI
- Incremental Hydration: Hydrates components on demand with
@defertriggers (viewport, interaction, idle, timer, never) - Event Replay: Captures user interactions during hydration and re-dispatches them afterward
- Prerendering (SSG): Generates HTML at build time for static pages, served from CDN
- RenderMode: Per-route choice between Prerender, Server, and Client
- SEO: Meta tags, Open Graph, JSON-LD structured data for maximum visibility
- SSR Patterns:
isPlatformBrowser,TransferState,afterNextRender - Performance: Incremental Hydration reduces TTI by 50%+ and TBT by 70%+
In the next article of the series, we will explore Core Web Vitals in Angular, diving deep into how to measure, monitor, and optimize FCP, LCP, CLS, INP, and TTI to achieve a Lighthouse score of 90+ across all categories.
Related deep dive: 4 - Standalone Components: Angular senza NgModules.
Related deep dive: 5 - Signal Forms: i Form Reattivi del Futuro.
Frequently asked questions
What is Server-Side Rendering (SSR) in Angular?
Server-Side Rendering consists of generating a page's HTML on the server rather than in the browser, allowing the browser to display content immediately without waiting for JavaScript to download and execute.
How does Incremental Hydration solve the problem with Full Application Hydration?
Incremental Hydration solves the problem by allowing components to be hydrated on demand, only when needed, which improves interactivity metrics and reduces CPU-intensive operations that block the main thread.
What is Event Replay in Angular and how does it work?
Event Replay is a mechanism based on Google's library that works in three phases to solve the problem of lost events during traditional SSR, allowing users to interact with the application without repeating their actions.
Why is Prerendering (SSG) important for SEO in Angular applications?
Prerendering generates page HTML at build time rather than on each request, allowing search engine crawlers to read server-generated HTML directly without having to execute JavaScript.
Commenti
Caricamento commenti...
Sign in to comment