๐ Angular SSR and Incremental Hydration: Faster Apps, Better Core Web Vitals
Server-side rendering used to mean a slow, flashy client takeover. Angular's incremental hydration changes all of that โ here's what it is, how to set it up, and how it improves your Lighthouse scores
Hi ๐, I'm Tushar Patil. Currently I am working as Frontend Developer (Angular) and also have expertise with .Net Core and Framework.
๐ค The performance problem SSR was supposed to solve
When you ship a standard Angular app, the browser receives a mostly empty HTML shell โ just a <app-root></app-root> tag and a big JavaScript bundle. The user stares at a blank screen until that bundle downloads, parses, and executes. Only then do they see anything. ๐ฉ
That's bad for users and worse for Google. Core Web Vitals โ the metrics that Google uses to score your page speed and factor into SEO rankings โ care deeply about how quickly users see content and can interact with it.
Server-Side Rendering (SSR) was the first fix: render the full HTML on the server, send it to the browser immediately, and let the user see real content before any JavaScript loads. But SSR introduced a new problem: the hydration gap. ๐ฐ
The server sends you a beautiful, rendered page. Then Angular's JavaScript arrives, tears down that DOM, and re-renders everything from scratch. For a brief moment, the page flickers. Layout shifts happen. Users click on buttons that don't respond yet because the JavaScript isn't ready. That's a terrible experience.
Angular solved this in three stages:
| Version | Milestone |
|---|---|
| Angular 16 | Full-application hydration (developer preview) |
| Angular 17 | Full hydration stable โ
ยท @defer blocks introduced |
| Angular 18 | Event replay โ captures clicks before hydration completes |
| Angular 19 | Incremental hydration (developer preview) |
| Angular 20 | Incremental hydration + route-level render modes stable โ ๐ |
๐ง Understanding the three concepts
Before diving into code, let's make sure the terminology is clear.
๐ฅ๏ธ SSR (Server-Side Rendering)
The server renders your Angular app to full HTML and sends it to the browser. The user sees meaningful content instantly โ no blank screen. But the page is static until Angular's JavaScript loads and "wakes it up."
๐ง Hydration
The process of Angular taking over a server-rendered DOM without destroying it. Instead of rebuilding every DOM node from scratch, Angular walks the existing HTML, matches it to the component tree, and attaches event listeners in place. Without hydration, Angular would destroy and re-render the application's DOM, resulting in a visible UI flicker that negatively impacts Core Web Vitals like LCP and causes layout shifts.
โก Incremental Hydration
Incremental hydration is a performance improvement that builds on top of full application hydration. It can produce smaller initial bundles while still providing an end-user experience comparable to full application hydration. Smaller bundles improve initial load times, reducing First Input Delay (FID) and Cumulative Layout Shift (CLS).
In plain terms: instead of hydrating your entire app at once when JavaScript loads, you hydrate individual components on demand โ when they scroll into view, when the user interacts with them, or when the browser is idle. The rest of the page stays as fast, static HTML until it's needed. ๐ฏ
๐ Why this matters: Core Web Vitals explained
Google scores your page on three key metrics. Here's how Angular's SSR stack improves each one:
๐จ LCP โ Largest Contentful Paint
How quickly does the largest visible element appear?
Without SSR, LCP is slow because the browser waits for JavaScript before rendering anything. With SSR + hydration, the server sends fully-rendered HTML โ so the hero image or headline appears immediately, giving you a fast LCP.
โก FID / INP โ First Input Delay / Interaction to Next Paint
How quickly does the page respond to user interaction?
This is where incremental hydration shines. By deferring hydration to critical moments, Angular apps load faster, respond more quickly to user actions, and minimize visual shifts from unnecessary DOM reflows. A component that's still dehydrated doesn't block the main thread โ so the rest of the page stays responsive.
๐ CLS โ Cumulative Layout Shift
Do elements jump around as the page loads?
Incremental hydration also lets you use deferrable views for content that may not have been deferrable before โ specifically, you can now use deferrable views for content above the fold. Prior to incremental hydration, putting a @defer block above the fold would result in placeholder content rendering and then being replaced by the main template, causing a layout shift. Incremental hydration means the main template renders with no layout shift on hydration. ๐
๐ SEO bonus
Search engines index the server-rendered HTML before any JavaScript runs. With SSR, your content is fully crawlable โ dynamic Angular apps become as SEO-friendly as static sites.
๐ ๏ธ Part 1: Setting up SSR
New project with SSR
The easiest way โ pass the --ssr flag when creating a new project:
ng new my-app --ssr
This generates everything you need: main.server.ts, server.ts (an Express server), and the correct app.config.ts with hydration pre-configured. โ
Adding SSR to an existing project
ng add @angular/ssr
The schematic creates the server files and updates your angular.json automatically. Once done, your project structure gains:
src/
app/
app.config.ts โ application providers (hydration lives here)
app.config.server.ts โ server-specific providers
main.ts โ client bootstrap
main.server.ts โ server bootstrap
server.ts โ Express + Angular SSR engine
The generated app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(), // ๐ง full-app hydration enabled
],
};
At this point you have SSR + full hydration. The page renders on the server, Angular takes over on the client without destroying the DOM, and there's no flicker. Good โ but we can do better. ๐
๐๏ธ Part 2: Enabling incremental hydration
Enable incremental hydration by adding the withIncrementalHydration() function to the provideClientHydration provider.
import {
ApplicationConfig,
} from '@angular/core';
import {
provideClientHydration,
withIncrementalHydration,
} from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(
withIncrementalHydration() // โก incremental hydration enabled
),
],
};
๐ก Incremental hydration automatically enables event replay under the hood. If you already have
withEventReplay()in your provider list, you can safely remove it after enabling incremental hydration.
That's the entire setup. One function call. Now you can start marking components for incremental hydration in your templates. ๐
๐ฏ Part 3: The @defer block with hydrate triggers
Incremental hydration is built on top of Angular's @defer primitive โ the same syntax you may already use for lazy loading. The key addition is the hydrate trigger.
Adding a hydrate trigger to a defer block tells Angular that it should load that block's dependencies during server-side rendering and render the main template rather than the @placeholder. When client-side rendering, the dependencies are still deferred, and the defer block content stays dehydrated until its hydrate trigger fires.
Here's the basic pattern:
@defer (hydrate on idle) {
<app-heavy-sidebar />
} @placeholder {
<div class="sidebar-skeleton">Loading...</div>
}
The server renders <app-heavy-sidebar /> fully โ the user sees it immediately. On the client, it stays as static HTML until the browser is idle, at which point Angular hydrates it and makes it interactive. ๐
๐๏ธ All hydrate triggers
Angular provides a variety of triggers to control hydration behavior:
hydrate on idle โ Browser is idle
@defer (hydrate on idle) {
<app-analytics-widget />
} @placeholder {
<div class="widget-skeleton"></div>
}
Use for: Non-critical UI elements like sidebars, recommendation panels, supplementary info cards. Hydration waits until the browser has no higher-priority work. ๐ด
hydrate on viewport โ Scrolled into view
@defer (hydrate on viewport) {
<app-comments-section />
} @placeholder {
<div class="comments-skeleton" style="min-height: 400px"></div>
}
Use for: Comments, footers, below-the-fold sections โ anything the user hasn't seen yet. Zero JS cost until they scroll down. ๐
hydrate on interaction โ User clicks or taps
@defer (hydrate on interaction) {
<app-date-picker />
} @placeholder {
<div class="datepicker-placeholder">Select a date</div>
}
Use for: Complex widgets that are visible but rarely used immediately โ date pickers, map views, rich text editors. The user sees it, but Angular doesn't pay the JavaScript cost until they actually touch it. ๐ฑ๏ธ
hydrate on hover โ Mouse hovers over it
@defer (hydrate on hover) {
<app-product-quick-view />
} @placeholder {
<div class="product-card-static">{{ product.name }}</div>
}
Use for: Hover cards, tooltips, preview panels. A hover is a strong signal the user is about to interact. ๐ฑ๏ธ
hydrate on immediate โ As soon as possible
@defer (hydrate on immediate) {
<app-main-nav />
} @placeholder {
<nav class="nav-skeleton"></nav>
}
Use for: Critical interactive elements like navigation or primary call-to-action buttons that need to be ready the moment the page loads. โก
hydrate on timer โ After a delay
@defer (hydrate on timer(3s)) {
<app-chat-widget />
} @placeholder {
<div class="chat-bubble">Chat with us</div>
}
Use for: Chat widgets, subscription prompts โ things you want to load after the user has had a moment to engage with the page. โฑ๏ธ
hydrate when โ Custom condition
@defer (hydrate when userIsLoggedIn()) {
<app-user-dashboard />
} @placeholder {
<div class="login-prompt">Log in to view your dashboard</div>
}
Use for: Content that should only hydrate when a specific application state is true โ authenticated sections, feature-flagged components, etc. ๐
hydrate never โ Permanently static
@defer (hydrate never) {
<app-footer-copyright />
}
Use for: Truly static content that never needs JavaScript โ legal text, copyright notices, decorative elements. This is effectively a server-only component. ๐
๐ Combining loading and hydration triggers
The @defer block in an incremental hydration context controls two distinct phases โ loading (when to fetch the JavaScript chunk) and hydrating (when to execute the logic and attach listeners to the existing HTML). By combining these triggers, developers can design highly sophisticated performance profiles.
<!-- Load JS in background when idle, but only hydrate when user interacts -->
@defer (on idle; hydrate on interaction) {
<app-complex-chart [data]="chartData()" />
} @placeholder {
<div class="chart-shell" style="min-height: 300px">
<!-- Server-rendered chart image or skeleton here -->
</div>
}
This pattern is powerful for heavy widgets like charts or maps:
- ๐ฅ๏ธ Server renders the chart โ user sees it immediately (great LCP)
- ๐ค Browser idle โ JavaScript for the chart fetches in the background
- ๐ฑ๏ธ User clicks โ Angular hydrates and the chart becomes fully interactive
- ๐ฏ Result โ Fast LCP, zero Time to Interactive impact until interaction
๐๏ธ Part 4: Route-level render modes
Angular 20 also stabilised per-route render modes โ you can choose between SSR, SSG (pre-rendering), and CSR on a route-by-route basis. This lives in your server routes config:
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '', // homepage
renderMode: RenderMode.Prerender, // ๐ Static โ pre-rendered at build time
},
{
path: 'blog/:slug', // individual blog posts
renderMode: RenderMode.Prerender, // ๐ Static โ great for SEO
},
{
path: 'dashboard', // user dashboard โ personalised, can't pre-render
renderMode: RenderMode.Server, // ๐ฅ๏ธ SSR on every request
},
{
path: 'settings', // user settings โ no SEO needed
renderMode: RenderMode.Client, // ๐ป CSR โ skip SSR entirely
},
];
Register it in app.config.server.ts:
import { mergeApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig = mergeApplicationConfig(appConfig, {
providers: [
provideServerRendering(withRoutes(serverRoutes)),
],
});
export default serverConfig;
๐ก Don't force every route to use SSR. Use a route-based strategy โ pre-render what you can, SSR what must be dynamic, and CSR what doesn't need SEO.
๐ HTTP Transfer Cache: don't fetch twice
When your app makes HTTP calls in components during SSR, those calls run on the server. Without the transfer cache, the exact same calls would fire again on the client during hydration โ wasting bandwidth and adding latency. ๐
Angular's HttpTransferCache solves this automatically. By default, HttpClient caches all HEAD and GET requests during SSR and reuses them during hydration on the client. You can configure this behaviour using withHttpTransferCacheOptions inside provideClientHydration().
import {
provideClientHydration,
withIncrementalHydration,
withHttpTransferCacheOptions,
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withIncrementalHydration(),
withHttpTransferCacheOptions({
includePostRequests: true, // cache POST requests too if needed
})
),
],
};
โ ๏ธ Common pitfalls and how to avoid them
๐ซ DOM mismatch errors
The most common issue. Angular requires the same generated DOM structure on both the server and the client, including whitespaces and comment nodes. If the HTML produced by SSR is altered between server and client, the hydration process will encounter problems.
// โ This causes hydration mismatches
@if (isPlatformBrowser(this.platformId)) {
<user-greeting [name]="currentUser" />
}
// โ
Keep rendered content consistent โ use afterNextRender for browser-only logic
constructor() {
afterNextRender(() => {
// Browser-only init here โ runs after hydration, not during
this.initBrowserOnlyFeature();
});
}
Avoid using isPlatformBrowser in templates with @if or other conditionals to render different content on server and client. This causes hydration mismatches and layout shifts, negatively impacting user experience and Core Web Vitals. Instead, use afterNextRender for browser-specific initialization.
๐ซ Third-party DOM manipulation libraries
Libraries that depend on DOM manipulation, like D3 charts, may cause DOM mismatch errors when hydration is enabled. If you encounter DOM mismatch errors using one of these libraries, you can add the ngSkipHydration attribute to the component that renders using that library.
<!-- Opt this component out of hydration entirely -->
<app-d3-chart ngSkipHydration />
๐ซ Empty @placeholder causing layout shifts
Always give your @placeholder a minimum height matching the deferred content. A dimensionless placeholder causes a layout shift when the real content loads, directly hurting your CLS score. ๐
<!-- โ Empty placeholder โ causes layout shift -->
@defer (hydrate on viewport) {
<app-comments />
} @placeholder {
<div></div>
}
<!-- โ
Sized placeholder โ no layout shift -->
@defer (hydrate on viewport) {
<app-comments />
} @placeholder {
<div class="comments-skeleton" style="min-height: 400px">
Loading comments...
</div>
}
๐ซ Server-only providers leaking to the client
Use app.config.server.ts for providers that should only exist on the server (like mock APIs or server-side auth). Never put server-specific logic in app.config.ts โ that file runs on both platforms.
๐ Setup checklist
- ๐ฅ๏ธ Add SSR:
ng add @angular/ssrorng new my-app --ssr - ๐ง Confirm
provideClientHydration()is inapp.config.ts - โก Add
withIncrementalHydration()toprovideClientHydration() - ๐๏ธ Create
app.routes.server.tsand set per-route render modes - ๐พ Confirm
HttpTransferCacheis active (it's on by default withprovideClientHydration) - ๐ฏ Wrap heavy components in
@deferblocks with appropriatehydratetriggers - ๐ Give all
@placeholderblocks a meaningful minimum height - ๐ซ Remove any
isPlatformBrowserchecks from templates - ๐ Add
ngSkipHydrationto any third-party DOM manipulation components - ๐ Run Lighthouse before and after to measure your CWV improvements
๐ฏ Quick decision guide: which trigger to use?
| Component type | Recommended trigger |
|---|---|
| Navigation / primary CTA | hydrate on immediate |
| Hero section content | SSR only (no defer needed) |
| Above-the-fold widgets | hydrate on idle |
| Below-the-fold sections | hydrate on viewport |
| Complex interactive widgets | hydrate on interaction |
| Hover cards / previews | hydrate on hover |
| Chat / subscription prompts | hydrate on timer(3s) |
| Auth-gated content | hydrate when condition() |
| Purely decorative / legal text | hydrate never |
๐ Wrapping up
Angular's SSR story has matured dramatically. What started as a rough "render on server, destroy and re-render on client" approach in Angular Universal is now a sophisticated, granular, trigger-based hydration system that gives you real control over your Core Web Vitals.
The power of incremental hydration is in that combination: the server renders the component so the user sees full content immediately, then Angular fetches the JavaScript in the background on idle, and only hydrates when the user actually interacts โ resulting in fast LCP and zero Time to Blocking impact.
If your Angular app doesn't use SSR yet, ng add @angular/ssr is the fastest performance win you can make today. If you're already on SSR, adding withIncrementalHydration() and wrapping your heavy components in @defer (hydrate on ...) blocks is the next step. Your Lighthouse scores โ and your users โ will thank you. ๐