Incremental Hydration
Full hydration walks the entire server-rendered DOM and attaches the framework to every component as soon as the page loads, which means you pay the JavaScript cost for parts of the page the user may never reach. Incremental hydration, stable since Angular 19, lets you defer that work per-region: a block renders on the server, ships as static HTML, and only hydrates (downloading its code and wiring up its event listeners) when a trigger you specify fires. The result is a smaller initial bundle, faster Time to Interactive, and content that is visible and SEO-friendly from the first paint.
How incremental hydration works
Incremental hydration is built on top of the @defer block. With ordinary client-side rendering, @defer shows a @placeholder until a trigger loads the deferred content. With incremental hydration the deferred content is fully rendered on the server and sent as real HTML — there is no empty placeholder gap. Angular leaves that DOM dormant (no event listeners, no change detection) until a hydrate trigger fires, at which point it fetches the component’s code and hydrates the existing DOM in place rather than re-rendering it.
To opt in, you pass withIncrementalHydration() to the client hydration provider. It implies withEventReplay() automatically, so interactions that happen before a region hydrates are queued and replayed once it becomes interactive.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import {
provideClientHydration,
withIncrementalHydration,
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withIncrementalHydration()),
],
};
Hydrate triggers
Inside a @defer block you add an @defer (hydrate ...) clause. The hydrate keyword distinguishes a hydration trigger from a regular load trigger. The available triggers mirror the familiar @defer ones, plus a special never.
| Trigger | Hydrates when… |
|---|---|
hydrate on idle | the browser is idle (requestIdleCallback); the default-feeling choice |
hydrate on viewport | the block scrolls into the viewport |
hydrate on interaction | the user clicks, focuses, or otherwise interacts with the block |
hydrate on hover | the pointer hovers over the block |
hydrate on immediate | as soon as the client finishes its initial render |
hydrate on timer(duration) | a timer elapses, e.g. timer(5s) |
hydrate when condition | a boolean expression becomes true |
hydrate never | never — the block stays static, inert HTML forever |
<!-- product-page.component.html -->
@defer (hydrate on viewport) {
<app-related-products [categoryId]="categoryId()" />
} @placeholder {
<div class="skeleton">Loading recommendations…</div>
}
Because the content is server-rendered, the @placeholder is only used as a fallback during pure client-side navigation; on the initial SSR load the user sees the real component immediately.
Combining triggers and never
You can list several triggers and the block hydrates when the first one fires. Use hydrate never for content that should remain on the page for SEO and layout but never needs interactivity — for example a static legal footer.
@defer (hydrate on interaction; hydrate on timer(8s)) {
<app-comment-thread [postId]="postId()" />
} @placeholder {
<p>Comments</p>
}
@defer (hydrate never) {
<app-license-notice />
}
Tip:
hydrate neverkeeps the JavaScript for that component out of the user’s session entirely. It is the most aggressive saving available — reach for it on truly static regions.
Nested defer blocks
When you nest @defer (hydrate ...) blocks, hydrating an outer block does not automatically hydrate the inner ones. Each block keeps its own trigger. This lets you hydrate a wrapping section on viewport while leaving an expensive child inside it on interaction, hydrating the heavy widget only if the user actually engages with it.
@defer (hydrate on viewport) {
<section class="dashboard">
<h2>Activity</h2>
@defer (hydrate on interaction) {
<app-live-chart [data]="metrics()" />
} @placeholder {
<img src="/chart-snapshot.png" alt="Activity chart" />
}
</section>
}
Verifying it works
Build and serve with SSR, then inspect the network panel. Code for a deferred region should only be requested when its trigger fires. Angular also logs hydration mismatches to the console in dev mode.
ng build && node dist/your-app/server/server.mjs
Output:
Node Express server listening on http://localhost:4000
✔ Server-side rendered: /product/42
related-products-chunk.js ── not requested until viewport reached
Scrolling the app-related-products block into view triggers the lazy chunk download and the region becomes interactive, with any clicks made during the gap replayed via event replay.
Best practices
- Wrap regions in
@defer (hydrate ...)only when they are below the fold or non-critical — above-the-fold, interaction-heavy content is better fully hydrated. - Prefer
hydrate on viewportfor long pages andhydrate on interactionfor widgets the user may never touch (modals, tabs, comment threads). - Use
hydrate neverfor purely presentational, SEO-relevant content to eliminate its JavaScript completely. - Keep
@placeholderblocks lightweight and visually close to the real content, since they only appear during client-side navigation. - Ensure deferred components avoid direct
window/documentaccess at construction time so server rendering stays clean. - Test with throttled network and CPU to confirm the deferred chunks load on the triggers you expect and not eagerly.