Skip to main content

6 - Angular SSR and Incremental Hydration

Server-Side Rendering in Angular 21: full hydration, incremental hydration with @defer, event replay, SSG prerendering, and SEO optimization.

What do you think about this article?

At a Glance

Server-Side Rendering in Angular 21: full hydration, incremental hydration with @defer, event replay, SSG prerendering, and SEO optimization.

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 @defer triggers: 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
1Angular Signals: From Zero to ProductionSignals API
2Zoneless Change Detection: Goodbye Zone.jsChange detection
3New Template Syntax: @if, @for, @deferControl flow blocks
4Standalone ComponentsModule-free architecture
5Signal FormsReactive forms with Signals
6You are here - SSR and Incremental HydrationServer-side rendering
7Core Web Vitals in AngularPerformance optimization
8Angular PWAProgressive Web Apps
9Advanced Dependency InjectionDI patterns and tokens
10Migrating from Angular 17 to 21Step-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.

  1. HTTP Request: The browser (or a crawler) requests a URL
  2. Server-side rendering: Express receives the request, Angular runs the app and produces HTML
  3. HTML delivery: The server sends the complete HTML with rendered content
  4. First Contentful Paint: The browser immediately displays the content
  5. JS Download: The browser downloads the application's JavaScript bundles
  6. Hydration: Angular "reanimates" the static HTML, attaching event listeners and reactivity
  7. Interactive application: The app becomes fully functional
Creating a new Angular project with SSR

# 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 - Enabling hydration

// 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, or localStorage during server rendering
  • Using Date.now() or Math.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.

Incremental Hydration with @defer and hydrate triggers

<!-- 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 - Configuration for 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 requires provideClientHydration() as well
  • Content inside @defer is still rendered on the server: the trigger only controls when it gets hydrated on the client
  • The @placeholder and @loading blocks of @defer are 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

app.config.server.ts - 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

app.routes.server.ts - Per-route 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 - Express server for SSR

// 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:

  1. Capture: A small inline script (injected into the HTML by the server) captures all user events before Angular is ready
  2. Buffer: Events are stored in a queue with all necessary information (event type, target, timestamp)
  3. Replay: When the component's hydration is complete, events are re-dispatched in the correct order
Enabling Event Replay in configuration

// 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:

  1. The event is captured by jsaction
  2. The click trigger activates the component's hydration
  3. Angular downloads the lazy code for the component and hydrates it
  4. The captured event is re-dispatched on the now-interactive component
  5. 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

Route configuration for prerendering with parametric routes

// 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 for meta tag management in SSR

// 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)

Adding structured data for blog articles

// 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

Platform checking with PLATFORM_ID

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.

TransferState to avoid duplicate requests

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.

afterNextRender for browser-only initialization

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

Optimized layout with differentiated hydration strategies

<!-- 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

Monitoring Core Web Vitals in production

// 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 window without guards: Always use isPlatformBrowser or afterNextRender
  • 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 never for 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 @defer triggers (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.

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.

Sources and further reading

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects