Angular 76 🅰️ Hydration and SSR Best Practices
In the previous chapter, you set up Angular Universal and sent your first server-rendered page to the browser. The server produced HTML, the browser painted it immediately, and the user saw content before the JavaScript bundle finished downloading. That is the promise of SSR. But there is a second act that determines whether the promise is kept or broken: hydration.
Hydration is the process of activating server-rendered HTML on the client. Without it, Angular destroys the server-rendered DOM and rebuilds everything from scratch, causing a visible flash and wasting the work the server already did . With it, Angular attaches to the existing DOM nodes, restores application state, and makes the page interactive — without re-rendering . This chapter covers the best practices that make hydration work: enabling it correctly, writing hydration-compatible code, using TransferState to eliminate duplicate HTTP requests, and adopting incremental hydration to hydrate only what the user needs.
Key point: Hydration is not optional in modern Angular SSR. It is the mechanism that makes SSR worthwhile. A server-rendered page without hydration is a static snapshot that gets thrown away when the JavaScript loads. A server-rendered page with hydration is a page that becomes interactive without flicker, without duplicate rendering, and without losing the user’s early interactions.
Why hydration best practices matter
SSR without hydration is a half-measure. The server renders the page, but the client discards it. This creates three problems that hydration solves — and that best practices ensure are actually solved.
The flicker problem. Without hydration, Angular destroys the server-rendered DOM and re-creates it from scratch. The user sees the content disappear and reappear. On a slow connection, this flash can last for seconds . Hydration eliminates the flash by reusing the existing DOM.
The duplicate work problem. The server already rendered the page. The client should not have to render it again. Hydration reuses the server’s work, reducing the time to interactive and improving Core Web Vitals like Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS) .
The lost interaction problem. A server-rendered page looks interactive before it is interactive. The user clicks a button, and nothing happens because the event listeners have not been attached yet. This is the “uncanny valley” of SSR . Event Replay solves it by capturing interactions during the hydration window and replaying them once hydration completes .
The trade-off. Hydration imposes constraints. The DOM structure on the client must match the server exactly. Direct DOM manipulation, random values in templates, and invalid HTML structure all cause hydration mismatches . Following best practices means writing code that respects these constraints, and knowing when to use ngSkipHydration as a last resort.
a. Enabling Hydration and Event Replay
Hydration is enabled by adding provideClientHydration() to the application’s bootstrap providers . In applications generated with the Angular CLI’s SSR schematic, this provider is already present. For custom setups, it must be added manually.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideClientHydration(withEventReplay()),
],
});
The withEventReplay() function captures user interactions — clicks, key presses, mouse events — that occur before hydration completes and replays them once the application becomes interactive . Without it, a user who clicks a button during the hydration window sees nothing happen. With it, the click is queued and executed when the button’s listener is finally attached .
Event Replay works through a global event dispatcher that listens at the document root. When an event bubbles up before hydration, it is stored. After hydration, it is replayed through the newly attached Angular listeners . This eliminates the “rage click” problem where users repeatedly tap a button that appears dead .
The hydration provider must be included in both the client and server bootstrap configurations. In a standard CLI project, the server configuration merges the client’s appConfig, so adding it once covers both . If the server uses a completely separate configuration object, the provider must be added there as well, or the server will not generate the hydration metadata that the client needs .
b. Writing Hydration-Compatible Code
Hydration requires that the DOM structure produced on the server matches the DOM structure expected on the client. Any mismatch causes the NG0500 error — “Hydration Node Mismatch” — and Angular falls back to destroying and re-rendering the affected part of the tree .
The most common cause of mismatches is direct DOM manipulation using native browser APIs. If a component calls document.createElement(), appendChild(), innerHTML, or outerHTML to alter the DOM outside of Angular’s template system, the server-rendered HTML will not contain those changes, and the client’s DOM will not match .
// ❌ Bad — direct DOM manipulation causes mismatch
@Component({
selector: 'app-bad',
template: '<div #container></div>',
})
export class BadComponent implements AfterViewInit {
constructor(private elementRef: ElementRef) {}
ngAfterViewInit(): void {
this.elementRef.nativeElement.innerHTML = '<p>Dynamic content</p>';
}
}
// ✅ Good — use Angular templating
@Component({
selector: 'app-good',
template: '<p *ngIf="showContent()">Dynamic content</p>',
})
export class GoodComponent {
showContent = signal(false);
ngAfterViewInit(): void {
this.showContent.set(true);
}
}
The good version uses Angular’s template syntax, which means both the server and the client generate the same DOM structure . When the signal changes, Angular updates the DOM through its normal change detection, and hydration remains intact.
Another source of mismatches is non-deterministic values in templates. Date.now(), Math.random(), and new Date() produce different values on the server and client. A template that renders {{ Math.random() }} will hydrate with a different value than the server produced, causing a mismatch . The fix is to compute these values on the server, store them in TransferState, or use a stable identifier that is generated once and reused.
Invalid HTML structure also causes mismatches. Browsers automatically correct certain invalid structures — adding <tbody> to tables that lack it, closing <p> tags that contain block-level elements, and rejecting nested <a> tags. If the server produces HTML that the browser auto-corrects, the DOM structures diverge . The fix is to write valid HTML: always include <tbody> in tables, avoid block elements inside <p>, and never nest anchors.
When a component genuinely cannot be made hydration-compatible — for example, a third-party library that manipulates the DOM directly — the ngSkipHydration attribute can be added to the component’s host node . This disables hydration for that component and its children, allowing Angular to re-render it from scratch while keeping the rest of the page hydrated.
@Component({
selector: 'app-dynamic-widget',
template: '...',
host: { ngSkipHydration: 'true' },
})
export class DynamicWidgetComponent {}
The ngSkipHydration attribute should be a last resort. The Angular documentation describes it as “a bug that needs to be fixed” rather than a feature . Every use of it means a portion of the page loses the benefit of hydration.
c. TransferState and Incremental Hydration
Two additional features complete the hydration best-practices picture: TransferState and incremental hydration.
TransferState eliminates duplicate HTTP requests between the server and the client. During SSR, Angular fetches data to render the page. Without TransferState, the client would fetch the same data again when it hydrates. TransferState serializes the server-fetched data into the HTML and makes it available to the client, which reuses it instead of re-requesting .
Angular’s HttpClient automatically caches GET and HEAD requests during SSR and transfers them when hydration is enabled . The cache is controlled by withHttpTransferCacheOptions(), which allows configuring whether POST requests are cached, whether requests with auth headers are cached, and which URLs are included or excluded .
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
bootstrapApplication(AppComponent, {
providers: [
provideClientHydration(
withHttpTransferCacheOptions({
includePostRequests: false,
})
),
],
});
The HttpClient transfer cache stops being used once the application becomes stable in the browser. After that, normal HTTP behavior resumes . The data in TransferState is serialized as JSON, so only serializable values can be transferred. Dates must be converted to strings, and complex types must be reconstructed on the client .
Incremental hydration is the most advanced hydration feature. It allows parts of the application to remain dehydrated and to hydrate on demand, triggered by the same conditions as @defer blocks . Instead of downloading all JavaScript and hydrating the entire page at once, incremental hydration downloads only what is needed and hydrates only what the user interacts with.
Incremental hydration is enabled with withIncrementalHydration(). It depends on and automatically enables Event Replay — if withEventReplay() is already present, it can be removed .
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
bootstrapApplication(AppComponent, {
providers: [provideClientHydration(withIncrementalHydration())],
});
Hydration triggers are added to @defer blocks with the hydrate keyword :
@defer (hydrate on viewport) {
<large-chart />
} @placeholder {
<div>Chart placeholder</div>
}
The available hydrate triggers are hydrate on idle, hydrate on viewport, hydrate on interaction, hydrate on hover, hydrate on immediate, and hydrate on timer. There is also hydrate when for custom conditions, and hydrate never for content that should never hydrate, such as static footers or purely informational sections .
The hydrate on viewport trigger is particularly valuable because it allows @defer blocks to be used above the fold without causing layout shift. Prior to incremental hydration, a @defer block above the fold would render its placeholder first and then swap in the main content, causing a visible shift. With incremental hydration, the server renders the main template, and the client hydrates it in place without a shift .
One important constraint: incremental hydration follows a hierarchy. A child cannot hydrate inside a dehydrated parent. If a child’s hydrate trigger fires, Angular automatically hydrates the parent context to support it . This means @defer blocks should be designed to be self-contained to avoid a “waterfall” effect where interacting with one component hydrates a large portion of the page .
Complete Example Session
This session builds a hydration-compatible product page with TransferState, Event Replay, and incremental hydration.
// ============================================
// PART 1: THE HYDRATION PROVIDER
// ============================================
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withEventReplay, withIncrementalHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withEventReplay(),
withIncrementalHydration(),
withHttpTransferCacheOptions({
includePostRequests: false,
})
),
],
};
// ============================================
// PART 2: THE HYDRATION-COMPATIBLE COMPONENT
// ============================================
import { Component, input, signal } from '@angular/core';
@Component({
selector: 'product-price',
template: `
<span class="price">{{ price() | currency }}</span>
@if (showDiscount()) {
<span class="discount">-{{ discount() }}%</span>
}
`,
})
export class ProductPriceComponent {
price = input.required<number>();
discount = input(0);
showDiscount = signal(false);
ngOnInit() {
this.showDiscount.set(this.discount() > 0);
}
}
// ============================================
// PART 3: THE TRANSFERSTATE SERVICE
// ============================================
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferState, makeStateKey } from '@angular/platform-browser';
import { isPlatformServer } from '@angular/common';
import { Observable, of, tap } from 'rxjs';
export interface Product {
id: string;
name: string;
price: number;
discount: 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[]> {
if (this.transferState.hasKey(PRODUCTS_KEY)) {
const products = this.transferState.get(PRODUCTS_KEY, []);
this.transferState.remove(PRODUCTS_KEY);
return of(products);
}
return this.http.get<Product[]>('/api/products').pipe(
tap(products => {
if (isPlatformServer(this.platformId)) {
this.transferState.set(PRODUCTS_KEY, products);
}
})
);
}
}
// ============================================
// PART 4: THE INCREMENTAL HYDRATION BOUNDARY
// ============================================
@Component({
selector: 'product-reviews',
template: `
<h3>Reviews</h3>
@for (review of reviews(); track review.id) {
<review-card [review]="review" />
}
`,
})
export class ProductReviewsComponent {
reviews = input.required<Review[]>();
}
// Parent template:
/*
@defer (hydrate on viewport) {
<product-reviews [reviews]="reviews()" />
} @placeholder {
<div>Reviews will load when visible</div>
}
*/
// ============================================
// PART 5: THE STATIC CONTENT WITH hydrate never
// ============================================
@Component({
selector: 'product-footer',
template: `
<footer>
<p>© 2025 Example Store</p>
<p>Terms of Service</p>
</footer>
`,
})
export class ProductFooterComponent {}
// Parent template:
/*
@defer (hydrate never) {
<product-footer />
}
*/
// ============================================
// PART 6: THE HYDRATION MISMATCH FIX
// ============================================
// ❌ Bad — random value in template
@Component({
selector: 'bad-widget',
template: '<div [id]="randomId">Widget</div>',
})
export class BadWidgetComponent {
randomId = Math.random().toString(36);
}
// ✅ Good — deterministic ID
@Component({
selector: 'good-widget',
template: '<div id="widget-1">Widget</div>',
})
export class GoodWidgetComponent {}
// ============================================
// PART 7: THE NG SKIP HYDRATION
// ============================================
import { Component } from '@angular/core';
@Component({
selector: 'third-party-chart',
template: '<canvas #chart></canvas>',
host: { ngSkipHydration: 'true' },
})
export class ThirdPartyChartComponent {
// This component uses a library that manipulates the canvas directly.
// Hydration is skipped, and Angular re-renders it from scratch.
}
// ============================================
// PART 8: THE DOCUMENT TOKEN
// ============================================
import { DOCUMENT } from '@angular/common';
@Component({
selector: 'canonical-link',
template: '',
})
export class CanonicalLinkComponent {
private document = inject(DOCUMENT);
setCanonical(href: string) {
const link = this.document.createElement('link');
link.rel = 'canonical';
link.href = href;
this.document.head.appendChild(link);
}
}
// ============================================
// PART 9: THE REQUEST TOKEN
// ============================================
import { REQUEST, RESPONSE_INIT } from '@angular/core';
@Component({
selector: 'request-info',
template: '<p>Request URL: {{ requestUrl }}</p>',
})
export class RequestInfoComponent {
private request = inject(REQUEST);
private responseInit = inject(RESPONSE_INIT);
requestUrl = this.request?.url ?? 'browser';
setResponseHeader() {
if (this.responseInit) {
this.responseInit.headers = { 'X-Custom': 'value' };
}
}
}
// ============================================
// PART 10: THE VERIFICATION
// ============================================
// In the browser console, after loading the page:
// Look for hydration stats: "Angular hydrated X components, Y nodes"
// Use Angular DevTools to see hydration status per component
// Check the Network tab to verify no duplicate API requests
The ten parts cover the hydration provider, a hydration-compatible component, TransferState service, an incremental hydration boundary, hydrate never for static content, a hydration mismatch fix, ngSkipHydration, the DOCUMENT token, the REQUEST token, and verification.
Quick Reference
The Hydration Features
| Feature | Provider | Purpose |
|---|---|---|
| Basic hydration | provideClientHydration() | Reuse server DOM |
| Event Replay | withEventReplay() | Capture pre-hydration events |
| Incremental Hydration | withIncrementalHydration() | Hydrate on demand |
| HTTP Transfer Cache | withHttpTransferCacheOptions() | Reuse server HTTP responses |
The Hydrate Triggers
| Trigger | Fires When |
|---|---|
hydrate on idle | Browser is idle |
hydrate on viewport | Element enters viewport |
hydrate on interaction | User clicks or presses key |
hydrate on hover | Mouse hovers |
hydrate on immediate | After non-deferred content renders |
hydrate on timer | After specified duration |
hydrate when | Custom condition is true |
hydrate never | Never hydrate |
The Hydration Constraints
| Constraint | Consequence |
|---|---|
| DOM structure must match | NG0500 mismatch error |
| No direct DOM manipulation | Mismatch, fallback to re-render |
| No random values in templates | Mismatch, fallback to re-render |
| Valid HTML required | Browser auto-correction causes mismatch |
| Comment nodes must be preserved | CDN whitespace removal breaks hydration |
The SSR DI Tokens
| Token | Provides |
|---|---|
REQUEST | Current request object |
RESPONSE_INIT | Response initialization options |
REQUEST_CONTEXT | Additional request context |
DOCUMENT | Platform-agnostic document object |
Best Practices
✅ Do This:
// Enable hydration with event replay
provideClientHydration(withEventReplay()) // ✅
// Use TransferState for data fetched during SSR
transferState.set(PRODUCTS_KEY, products) // ✅
// Use hydrate on viewport for below-fold content
@defer (hydrate on viewport) { <heavy-chart /> } // ✅
// Use hydrate never for static content
@defer (hydrate never) { <footer /> } // ✅
// Use the DOCUMENT token instead of browser globals
inject(DOCUMENT) // ✅
❌ Don’t Do This:
// Don't manipulate the DOM directly
elementRef.nativeElement.innerHTML = '<p>...</p>' // ❌
// Don't use random values in templates
{{ Math.random() }} // ❌
// Don't forget ngSkipHydration for incompatible libraries
// Third-party chart lib will cause mismatch. // ❌
// Don't put secrets in TransferState
transferState.set(TOKEN_KEY, authToken) // ❌
// Don't nest @defer blocks without understanding hierarchy
// Child hydrate triggers hydrate the parent. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| NG0500 mismatch | Direct DOM manipulation | Use Angular templating or ngSkipHydration |
| NG0507 mismatch | HTML altered after SSR (CDN) | Disable CDN whitespace removal |
| Duplicate HTTP requests | TransferState not configured | Use provideClientHydration() |
| Hydration not working | Provider missing from server config | Add to both client and server |
| Layout shift on hydrate | @defer above fold without incremental | Use hydrate on viewport |
| Events lost during hydration | Event Replay not enabled | Add withEventReplay() |
Real-World Examples
1. Basic Hydration
provideClientHydration()
2. Event Replay
provideClientHydration(withEventReplay())
3. Incremental Hydration
provideClientHydration(withIncrementalHydration())
4. TransferState Service
transferState.set(PRODUCTS_KEY, products);
5. Hydrate on Viewport
@defer (hydrate on viewport) { <chart /> }
6. Hydrate Never
@defer (hydrate never) { <footer /> }
7. Skip Hydration
host: { ngSkipHydration: 'true' }
8. DOCUMENT Token
inject(DOCUMENT)
9. REQUEST Token
inject(REQUEST)
10. HTTP Transfer Cache
withHttpTransferCacheOptions({ includePostRequests: false })
Visual
The Hydration Lifecycle
┌──────────────────────────────────────────────┐
│ HYDRATION LIFECYCLE │
│ │
│ 1. Server renders HTML with markers │
│ │ │
│ ▼ │
│ 2. Browser paints content immediately │
│ │ │
│ ▼ │
│ 3. Event Replay captures interactions │
│ │ │
│ ▼ │
│ 4. Client bootstraps Angular │
│ │ │
│ ▼ │
│ 5. Hydration: reuse DOM, attach listeners │
│ │ │
│ ▼ │
│ 6. Replay captured events │
│ │ │
│ ▼ │
│ 7. Application fully interactive │
│ │
└──────────────────────────────────────────────┘
Incremental Hydration Triggers
┌──────────────────────────────────────────────┐
│ INCREMENTAL HYDRATION TRIGGERS │
│ │
│ hydrate on viewport │
│ └─ Below-fold content │
│ │
│ hydrate on interaction │
│ └─ User-initiated content │
│ │
│ hydrate on hover │
│ └─ Preloading content │
│ │
│ hydrate on idle │
│ └─ Non-critical content │
│ │
│ hydrate never │
│ └─ Static content (no interactivity) │
│ │
└──────────────────────────────────────────────┘
Hydration Mismatch Causes
┌──────────────────────────────────────────────┐
│ HYDRATION MISMATCH CAUSES │
│ │
│ ❌ Direct DOM manipulation │
│ innerHTML, appendChild, createElement │
│ │
│ ❌ Random values in templates │
│ Math.random(), Date.now() │
│ │
│ ❌ Invalid HTML structure │
│ <table> without <tbody> │
│ <div> inside <p> │
│ <a> inside <a> │
│ │
│ ❌ CDN whitespace/comment removal │
│ │
└──────────────────────────────────────────────┘
TransferState Flow
┌──────────────────────────────────────────────┐
│ TRANSFERSTATE │
│ │
│ Server: │
│ HTTP GET /api/products │
│ └─ Response stored in TransferState │
│ └─ Serialized into HTML │
│ │
│ Client: │
│ └─ Reads TransferState from HTML │
│ └─ Returns cached data, no HTTP call │
│ └─ Removes key after reading │
│ │
│ Result: one fetch, no duplication │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Hydration provider | provideClientHydration() |
| Event Replay | withEventReplay() |
| Incremental Hydration | withIncrementalHydration() |
| HTTP Transfer Cache | withHttpTransferCacheOptions() |
| Hydrate triggers | hydrate on viewport, hydrate on interaction, hydrate never |
| Mismatch error | NG0500 (node mismatch), NG0507 (HTML altered) |
| Skip hydration | ngSkipHydration on host node |
| SSR DI tokens | REQUEST, RESPONSE_INIT, DOCUMENT |
| TransferState | Server stores, client reads, no duplicate HTTP |
| Hierarchy rule | Parent must hydrate before child |
Key takeaways:
- Hydration activates server-rendered HTML on the client without re-rendering. It reuses the existing DOM, attaches event listeners, and restores application state, eliminating the flicker and wasted work of destroying and rebuilding .
- Event Replay captures interactions during the hydration window. Clicks and key presses that occur before the application is interactive are queued and replayed once hydration completes. This prevents the “rage click” problem .
- Hydration requires matching DOM structures. Direct DOM manipulation, random values in templates, and invalid HTML all cause hydration mismatches (NG0500). The fix is to use Angular’s templating APIs and write valid HTML .
- TransferState eliminates duplicate HTTP requests. The server stores fetched data in a serialized form that the client reads during hydration.
HttpClientdoes this automatically for GET and HEAD requests when hydration is enabled . - Incremental hydration hydrates on demand. With
hydrate on viewport,hydrate on interaction, and other triggers, parts of the page remain dehydrated until needed. This reduces the initial JavaScript bundle and improves Core Web Vitals . - Incremental hydration enables
@deferabove the fold. The server renders the main template, and the client hydrates it in place without layout shift. This was not possible before . ngSkipHydrationis a last resort. It disables hydration for a component and its children, causing that portion to be re-rendered. The Angular documentation describes it as “a bug that needs to be fixed” .
Remember: Hydration is what makes SSR worth doing. Without it, the server’s work is discarded, and the user sees a flash. With it, the server’s work is preserved, and the page becomes interactive without re-rendering. Enable provideClientHydration() with withEventReplay() and withIncrementalHydration(). Write code that produces the same DOM on both sides — no direct DOM manipulation, no random values in templates, valid HTML structure. Use TransferState to avoid duplicate HTTP requests. Use hydrate on viewport for below-fold content and hydrate never for static content. And when a third-party library makes hydration impossible, use ngSkipHydration as a documented exception, not a habit.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!