When the page arrives rendered from the server (SSR or prerender), the browser already has the complete DOM. Without hydration, Angular would start as in a CSR application: it would throw that DOM away and build it again — a visible flicker, a broken LCP, a layout shift. Hydration means Angular walks the existing DOM, matches it against the component tree and reuses it: it binds the bindings and attaches the listeners to the nodes that already exist.
Two optimisations were built on top of it: event replay — clicks made before hydration aren’t lost — and incremental hydration — parts of the page stay static HTML, with no JS downloaded, until they’re actually needed.
- Hydration reuses the HTML sent by the server instead of recreating it, so the page does not flicker on startup.
- Event replay remembers clicks made before JavaScript loads and replays them afterwards.
- Incremental hydration (
@deferwithhydrate on …) leaves parts of the page non-interactive until they are needed. Their code is only downloaded then. - The HTML from the server and the one in the browser must be identical. Otherwise you get mismatch errors, with
NG05xxcodes. ngSkipHydrationis the emergency exit for a component that changes the DOM directly.
provideClientHydration()starts incremental hydration by default, and it automatically enables event replay.withIncrementalHydration()is deprecated (removal planned in v24) — remove it from the config, it’s already the default.- The new opt-out option is
withNoIncrementalHydration().
What hydration does, step by step
On the server, after rendering, Angular annotates the HTML so it can be “found” later: the ngh attribute on each component host (an index into the hydration data), anchor comments for @if/@for/@defer, an HTML comment nghm as an integrity marker, jsaction on the elements that have listeners, and a <script id="ng-state"> with the hydration data (__nghData__) and the HTTP responses from the transfer cache.
@Component({
selector: 'app-product-card',
template: `
<h2>{{ product().name }}</h2>
@if (product().stock > 0) {
<button (click)="addToCart()">Add to cart</button>
}
`,
})
export class ProductCard {
readonly product = input.required<Product>();
addToCart() { /* ... */ }
}In the browser, at bootstrap, Angular no longer creates nodes: it looks for the existing ones, using the annotations. For each node it checks the type and the tag (not the text) and attaches the listeners. A different text isn’t an error — it is simply overwritten at the first change detection.
After the application becomes stable (no HTTP requests and no PendingTasks in progress), comes the final stage: Angular removes the server nodes that nobody claimed, replays the events from the queue and turns off the transfer cache. That’s why an application that doesn’t become stable has visible effects even when the DOM was reused correctly.
| Without hydration | Full hydration | Incremental hydration | |
|---|---|---|---|
| The server’s DOM | thrown away and recreated | reused | reused |
| JS needed at startup | all of it | all of it | only what is outside the @defer (hydrate …) blocks |
| When a block becomes interactive | after re-rendering | at bootstrap, the whole page at once | at the block’s trigger |
| Clicks before the JS | lost | replayed only with withEventReplay() | replayed; a click on a dehydrated block hydrates it |
provideClientHydration and its options
If you added SSR with ng add @angular/ssr, the provider is already in app.config.ts. With no arguments, in v22, you get the recommended set:
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
// v22, no arguments:
// ✓ non-destructive hydration (reuses the server’s DOM)
// ✓ HTTP transfer cache (GETs/HEADs made on the server aren’t repeated in the browser)
// ✓ incremental hydration (@defer with hydrate …) — includes event replay
// ✓ in dev mode: provideStabilityDebugging()
provideClientHydration(),
],
};
// app.config.server.ts does mergeApplicationConfig(appConfig, serverConfig),
// so the provider reaches the server too — mandatory, otherwise NG0505 appears.| Function | What it does | In v22 |
|---|---|---|
provideClientHydration() | non-destructive hydration + HTTP transfer cache + incremental hydration (with event replay) | the default set |
withNoIncrementalHydration() | turns off incremental hydration (and, with it, event replay) | new in v22 |
withEventReplay() | event replay without incremental hydration | included by default; explicit only with the opt-out above |
withI18nSupport() | hydrates components with i18n blocks too; without it they are re-rendered from scratch | optional |
withHttpTransferCacheOptions({…}) | configures which responses end up in the HTML: filter, includeHeaders, includePostRequests, includeRequestsWithAuthHeaders… | optional |
withNoHttpTransferCache() | turns off the transfer cache: each GET is made twice | optional |
withIncrementalHydration() | used to enable incremental hydration | deprecated since v22 (removal planned in v24) |
The provider must also exist in the server config — the app.config.server.ts generated by the CLI does mergeApplicationConfig(appConfig, serverConfig), so it’s there automatically. If it’s missing, the server doesn’t annotate the HTML, and in the browser the page is re-rendered from scratch (in dev mode, with the warning NG0505). Contradictory combinations (for example withNoHttpTransferCache() with withHttpTransferCacheOptions()) throw the error NG05001 in dev mode.
Event replay
The problem: the server’s HTML is visible long before the JS arrives (in the timeline on the SSR page that’s the yellow band). The user sees a button and presses it — but the button doesn’t have a listener yet.
The mechanism is based on JSAction, an event delegation library built into Angular:
- Marking — during serialisation, the server puts
jsaction="click:;"on every element that has a listener in the template. - Capturing — a small inline script, injected right after
<body>(the event contract), listens at thebodylevel for the event types used on the page and keeps them in memory. - Replaying — after hydration, when the application becomes stable, Angular sends the queued events, in order, to the real handlers.
It works for the browser’s native events (click, mouseover, focusin, etc.). With incremental hydration, the contract stays active after hydration too, so it can catch the events in blocks that are still dehydrated.
- Press “Reload page”, then click the buttons while the JS bar isn’t full.
Replay saves the action, but the button doesn’t react visually until hydration. The CSS states (:hover, :active, :focus-visible) work without JS — use them, so the user knows the click “registered”.
Incremental hydration with @defer
A regular @defer renders only the @placeholder on the server: the triggers don’t run on the server. A hydrate trigger changes that: the server loads the block’s dependencies and renders the main content. In the browser the block stays dehydrated — the HTML is visible, but the JS chunk of the components in it isn’t even downloaded — until the trigger fires. Then Angular downloads the chunk, hydrates the block and replays the queued events.
Two practical consequences. First: you can put @defer above the fold without a layout shift, because the final content is already on screen, not the placeholder. Second: an event on an element with a listener in a dehydrated block triggers its hydration, whatever the declared trigger — the click isn’t lost. The exception is hydrate never: its subtree isn’t annotated at all, so there is nothing to replay.
<app-header /> <!-- no @defer: hydrated at bootstrap -->
@defer (hydrate on interaction) {
<app-gallery [images]="product().images" /> <!-- the gallery’s JS: only at the first click -->
} @placeholder {
<div class="gallery-skeleton"></div> <!-- used only on client-side navigation -->
}
@defer (hydrate on hover) {
<app-recommendations [productId]="id()" />
} @placeholder {
<div class="reco-skeleton"></div>
}
@defer (hydrate on idle) {
<app-support-chat />
} @placeholder {
<button>Chat</button>
}
@defer (hydrate on viewport) {
<app-reviews [productId]="id()" /> <!-- below the fold: JS when it enters the screen -->
} @placeholder {
<div class="reviews-skeleton"></div>
}
@defer (hydrate never) {
<app-footer /> <!-- static HTML forever -->
}no @defer (main.js)dehydrated48 KB, not downloadedhydrate on interactiondehydrated36 KB, not downloadedhydrate on hoverdehydrated22 KB, not downloadedhydrate on idledehydrated30 KB, not downloaded↓ scroll down to the reviews ↓
hydrate on viewportdehydrated64 KB, not downloadedhydrate neverstatic, no JS0 KB (would be 18)- The simulation starts when the demo comes into view (or press “Reload”).
The hydration triggers
@defer (hydrate on idle) { … } <!-- requestIdleCallback -->
@defer (hydrate on idle(500)) { … } <!-- idle, but at the latest after 500 ms -->
@defer (hydrate on viewport) { … } <!-- the block’s content enters the screen -->
@defer (hydrate on viewport({ rootMargin: '200px' })) { … } <!-- literal options only -->
@defer (hydrate on interaction) { … } <!-- click / keydown in the block -->
@defer (hydrate on hover) { … } <!-- mouseover / focusin in the block -->
@defer (hydrate on immediate) { … } <!-- right after the rest of the page -->
@defer (hydrate on timer(2s)) { … } <!-- ms or s -->
@defer (hydrate when cartReady()) { … } <!-- condition from the parent component -->
@defer (hydrate never) { … } <!-- never; no other hydrate triggers -->
@defer (hydrate on viewport; hydrate on timer(5s)) { … } <!-- several = any of them (OR) -->
<!-- ✗ compile errors -->
@defer (hydrate on interaction(btn)) { … } <!-- interaction/hover don’t take a reference -->
@defer (hydrate on viewport({ trigger: el })) { … }
@defer (hydrate never; hydrate on idle) { … } <!-- never excludes any other hydrate trigger -->| Trigger | Fires when | Parameters |
|---|---|---|
hydrate on idle | the browser is free (requestIdleCallback) | optional, a timeout: idle(500) |
hydrate on viewport | the block’s content enters the screen | optional, literal IntersectionObserver options; no reference to another element |
hydrate on interaction | click or keydown in the block | none |
hydrate on hover | mouseover or focusin in the block | none |
hydrate on immediate | as soon as the rest of the page has finished rendering | — |
hydrate on timer(…) | after an interval | required, in ms or s |
hydrate when expr | the expression becomes truthy | works only on the outermost dehydrated block |
hydrate never | never: static HTML, the whole subtree included | can’t be combined with other hydrate triggers |
Several triggers on the same block combine with “any” (OR). hydrate when has one limit: the expression belongs to the parent component, which must itself be hydrated for the expression to be evaluated.
Nested blocks
The component tree is hierarchical: a child can’t be active without its parent. If the trigger fires on a nested block, Angular hydrates the chain from top to bottom — first the outermost dehydrated block, then one by one down to the one that asked.
@defer (hydrate on interaction) {
<app-product-tabs /> <!-- 1. the parent is hydrated first… -->
@defer (hydrate on hover) {
<app-size-guide /> <!-- 2. …then the child, even if the hover was on it -->
}
}Hydrate triggers in CSR and on navigation
Hydration is an optimisation of the initial load of server-rendered content. When the user reaches the same page through client-side navigation (a routerLink), or when the application has no SSR at all, the hydrate triggers are ignored: the block behaves like a regular @defer, with its @placeholder and the normal triggers. If you didn’t put any normal trigger, the compiler adds on idle.
@defer (on interaction; hydrate on viewport) {
<app-reviews />
} @placeholder {
<button>Show the reviews</button>
}
<!--
Initial load, server-rendered page:
→ the server renders <app-reviews /> (not the placeholder)
→ in the browser it stays static HTML until it enters the viewport (hydrate on viewport)
Client-side navigation (routerLink) or an application without SSR:
→ hydrate on viewport is ignored
→ the @placeholder is shown, the block loads on click (on interaction)
→ without a normal trigger, the default would have been on idle
-->@placeholderremains necessary — it doesn’t show on the initial load with SSR, but it shows on any client-side navigation to the page.hydrate nevermeans “never JS on the initial load”. On a later client-side render, the block still downloads its dependencies, with the normal trigger.- In an application without
provideClientHydration()(or withwithNoIncrementalHydration()), blocks withhydratetriggers produce the warning NG0508 in dev mode.
Mismatch errors
Hydration starts from a promise: the DOM in the browser is exactly the one the server produced, including comments and spaces. Anything that breaks the promise — the browser’s HTML parser, a third-party script, a CDN, a different template branch — ends in one of three situations: an error, a warning or, the most treacherous, no error but a visible re-render.
| Code | Means | Typical cause |
|---|---|---|
NG0500 | the node found isn’t the expected one (a different tag or type) or is missing | invalid HTML, direct DOM manipulation, a third-party script |
NG0501 | sibling nodes are missing | nodes removed from outside Angular |
NG0502 | the node isn’t found at the expected position | structure changed after the server |
NG0503 | projected nodes created outside Angular | createComponent with projectableNodes from the native DOM |
NG0504 | ngSkipHydration on an element that isn’t a component host | the attribute put on a div |
NG0505 | (warning) the annotations from the server are missing | provideClientHydration() is missing from the server config |
NG0506 | (warning) the application doesn’t become stable within 10 s | unfinished PendingTasks; with Zone.js, timers in the Angular zone |
NG0507 | the HTML was modified after the server | a CDN that removes the comments |
<p class="promo"> <div>Free delivery today</div> </p>
<p class="promo"><div>Free delivery today</div></p><p class="promo"></p><div>Free delivery today</div><p></p>
NG0500: During hydration Angular expected <div> but the node was not found.
Angular expected this DOM:
<p class="promo">
<div>…</div> <-- AT THIS LOCATION
…
</p>
To fix this problem:
* check the "PromoBanner" component for hydration-related issues
* check to see if your template has valid HTML structure
…What's happening: The server builds the DOM through APIs, so the invalid nesting survives until serialisation. The browser’s HTML parser, however, closes the <p> when it meets the <div>, and Angular no longer finds the expected node.
Fix: Valid HTML: <p> accepts only inline content. Use a <div class="promo"> as the parent, or a <span> inside.
@if (isPlatformBrowser(…)) in a template doesn’t throw an error, but it’s one of the most expensive mistakes: Angular throws away the server’s DOM for the block and rebuilds it, with a layout shift. Keep the output identical on both platforms and move the browser-only initialisation into afterNextRender — or use @defer, which renders the placeholder on the server. The variants are on the Server-compatible code page.
ngSkipHydration: the emergency exit
For a component that can’t be fixed immediately (typically a library that draws directly into the DOM, like D3), the ngSkipHydration attribute on its host takes it out of hydration: the component and its children are destroyed and re-rendered, exactly as without hydration. It works only on component hosts (otherwise NG0504), and put on the root component it disables hydration for the whole application.
<!-- on the host of the component that manipulates the DOM directly (e.g. a D3 chart) -->
<app-sales-chart ngSkipHydration [data]="sales()" />
<!-- ✗ NG0504: only on component hosts, not on ordinary elements -->
<div ngSkipHydration><app-sales-chart [data]="sales()" /></div>Debugging
- The console, in dev mode: once the application becomes stable, a summary appears. If you don’t see it, hydration didn’t start (check NG0505).
- Angular DevTools shows the hydration state of each component and can turn on an overlay over the hydrated parts; on a mismatch error, it highlights the guilty component.
- View source vs Elements: View source is the server’s HTML, Elements is the current DOM. The differences between them (an extra
tbody, an injected banner) are exactly the causes of NG0500. - Turn off JavaScript in DevTools and reload: you see exactly what the user receives before hydration.
- NG0506:
provideStabilityDebugging()lists the tasks keeping the application unstable (with Zone.js, also importzone.js/plugins/task-tracking). preserveWhitespacesmust have the same value intsconfig.app.jsonandtsconfig.server.json; the default (false) is the recommended one.
Angular hydrated 14 component(s) and 212 node(s), 0 component(s) were skipped.
5 defer block(s) were configured to use incremental hydration.
Learn more at https://angular.dev/guide/hydration.When YES, when NO
- Hydration: whenever you have SSR or prerender. Without it, SSR loses half its point (the DOM is rebuilt).
- Event replay: on by default; it matters most on pages with quick actions (cart, like, menu) and slow networks.
hydrate on viewportfor everything below the fold: reviews, similar products, a rich footer.hydrate on interaction/hoverfor heavy widgets that sit idle until the first touch: galleries, filters, size selectors.hydrate neverfor truly static content: an article, terms, a footer with no logic.
- Applications without SSR: the
hydratetriggers do nothing. Use @defer with normal triggers. @defer (hydrate …)on components of a few KB: the extra chunk and request cost more than you save.hydrate neveron something that has listeners or state: it will never work on the initial load.ngSkipHydrationas a permanent “fix”: fix the cause (valid HTML,afterNextRender, DOM manipulated through Angular).isPlatformBrowserbranches in the template to hide problems: the alternative is@deferorafterNextRender.
Common pitfalls
nativeElement.appendChild, innerHTML, document.querySelector(…).remove() in components that also run on the server produce structures that hydration can’t match. Use the template (@if, @for, bindings, Renderer2) and postpone strictly-browser code to afterNextRender.
- Invalid HTML: a
<div>inside a<p>, an<a>inside an<a>, a table without a<tbody>. Validate the template; the browser “repairs” HTML differently from how the server wrote it. - Different values on the server and the client (
Date.now(),Math.random(), generated IDs, time zones): no error, but the text changes in front of the user. Transfer the value withTransferState. - Third-party scripts (analytics, consent, chat) that modify the DOM before hydration: load them afterwards, with
afterNextRender. withEventReplay()andwithIncrementalHydration()left in “just in case” in v22: the first is redundant, the second is deprecated. A plainprovideClientHydration()is enough.- A
@placeholderremoved from the blocks withhydrate, because “it isn’t visible anyway”: on client-side navigation it is. - Personal data in the transfer cache: responses end up in the HTML. Requests with
Authorization/Cookieare excluded by default — don’t include them withincludeRequestsWithAuthHeaderswithout a reason; usefilterortransferCache: false. - A CDN with HTML minification that removes the comments → NG0507 on every page.
Cheat sheet
// ── Config (app.config.ts; also reaches the server through mergeApplicationConfig)
provideClientHydration(); // v22: DOM + transfer cache + incremental + replay
provideClientHydration(withNoIncrementalHydration(), withEventReplay());
provideClientHydration(withI18nSupport());
provideClientHydration(withHttpTransferCacheOptions({ filter, includeHeaders, includePostRequests }));
provideClientHydration(withNoHttpTransferCache());
http.get(url, { transferCache: false }); // per-request opt-out
// ── Template
// @defer (hydrate on idle | idle(ms) | viewport | viewport({ rootMargin }) | interaction | hover | immediate | timer(2s))
// @defer (hydrate when cond) @defer (hydrate never) @defer (on idle; hydrate on viewport)
// <app-chart ngSkipHydration /> host: { ngSkipHydration: 'true' } ← last resort
// ── Errors
// NG0500 different node NG0501 sibling nodes missing NG0502 node missing NG0503 projected nodes created outside Angular
// NG0504 ngSkipHydration on a non-component NG0505 annotations missing (provider missing on the server)
// NG0506 the application doesn’t become stable within 10 s NG0507 HTML modified after the server (comments removed)
// NG0508 hydrate triggers without active incremental hydration