Angular 22: incremental hydration by default and resource caching in SSR
You ran ng update to Angular 22 and incremental hydration is now active without touching a line. If your app already used provideClientHydration(), the commit "make incremental hydration default behavior" turned it on for you. And the same version shipped the other missing piece of the SSR puzzle: caching resource() results in the TransferState. - Source: Angular v22.0.0 release notes
Both changes solve the same class of problem: the distance between what the server renders and what the browser rebuilds. One attacks the JavaScript you load; the other, the requests you repeat. Let's take them apart.
What exactly changed in v22
Before v22, incremental hydration was opt-in. It arrived as a developer preview in v19 with withIncrementalHydration(), went stable in v20, and became the default behavior in v22:
// main.ts
import {bootstrapApplication, provideClientHydration} from '@angular/platform-browser';
bootstrapApplication(App, {
providers: [provideClientHydration()],
});
With this single call, v22 gives you three things: DOM hydration, the HttpClient transfer cache, and incremental hydration. - Source: provideClientHydration API
Details worth knowing:
withIncrementalHydration()is deprecated. It keeps working in v22, but the official intent is to remove it in v24. If you have it, delete it. - Source: withIncrementalHydration API- Event replay turns on automatically. Incremental hydration depends on event replay and enables it by itself. If you had
withEventReplay()in your providers, you can remove it. - Source: Incremental hydration guide - Want the old behavior? There's an explicit opt-out:
// main.ts
import {provideClientHydration, withNoIncrementalHydration} from '@angular/platform-browser';
bootstrapApplication(App, {
providers: [provideClientHydration(withNoIncrementalHydration())],
});
If you come from my Angular rendering guide, this changes the map: the CSR/SSR/SSG model stays the same, but the "hybrid" part no longer requires configuration.
The hydration triggers
Incremental hydration works with @defer and hydrate triggers. Each trigger defines a hydration boundary: the server renders the main template, but the browser neither downloads nor hydrates that block until the trigger fires.
<!-- dashboard.html -->
@defer (hydrate on interaction) {
<analytics-panel />
} @placeholder {
<div>Panel loading…</div>
}
The full catalog:
hydrate on idle: when the browser is free. New in v22: it accepts a timeout,@defer (hydrate on idle(500)). - Source: v22.0.0 release noteshydrate on viewport: when the block enters the screen.hydrate on interaction: click or keydown on the element.hydrate on hover: mouseover or focusin.hydrate on immediate: right after the non-deferred content renders.hydrate on timer(500ms): after a set time.hydrate when expr: when an expression turns truthy.hydrate never: never; the block stays as static content on initial load.
You can combine load triggers with hydration triggers. On the initial (SSR) load, the hydrate trigger wins; on client-side navigations, the regular trigger does:
<!-- product-list.html -->
@defer (on idle; hydrate on interaction) {
<filters-panel />
} @placeholder {
<div>Filters</div>
}
One detail worth appreciating: before, an above-the-fold @defer swapped placeholder for content and caused layout shift. With incremental hydration, the server renders the main template from the start, so the content sits where it belongs and hydration moves zero pixels. The official guide frames the benefits qualitatively: smaller initial bundles that improve FID and CLS. There are no official LCP/INP figures published, so measure your own app before quoting numbers. - Source: Incremental hydration guide
Resource caching in SSR: the id option
Now the other half. When your app renders on the server, a resource() runs its loader to produce the initial HTML. And during hydration, the browser runs the same loader again. Same request, twice. In v22, the fix fits in one line:
// user-profile.ts
import {resource, Signal} from '@angular/core';
const userId: Signal<string> = getUserId();
const userResource = resource({
params: () => ({id: userId()}),
loader: ({params}) => fetchUser(params),
id: 'user-unique-id',
});
The id option is new on BaseResourceOptions and stable since v22.0. Here's how it works: on the server, the resolved value is stored in the TransferState and serialized into the initial HTML. On the client, if the data is in the cache, the resource initializes directly in the 'resolved' state and the loader never runs. - Source: Resource guide: Caching resource data with SSR
Real code that works: the user sees the profile without a "loading" flash and your backend receives one fewer request per visit. The rules, so this doesn't bite you:
- The
idmust be unique within your application and identical on server and client. If they don't match, the cache misses and the loader runs anyway. - The security warning is serious: the cached value is serialized into the page's HTML. If that HTML can be cached or shared between users, don't put
idon resources that load data specific to the user who triggered the render. - Source: same
What about httpResource? It needs nothing: it goes through HttpClient, which has had its own transfer cache on by default since v16. The id is for custom loaders: fetch(), third-party SDKs, Firestore snapshots, anything that doesn't ride HttpClient. - Source: SSR guide
How it all composes
The pieces fit like this:
- Route-level render modes decide which routes render on the server. Incremental hydration only acts where SSR happened: on client-side navigations, a
@deferwithhydratebehaves like a regular@deferand the@placeholderrenders. That's why it remains mandatory. - Source: Incremental hydration guide - Event replay queues events that arrive before hydration and replays them after. It's what makes the
hydrate on interactionpattern safe: the user can click before the block is alive and the click isn't lost. - The
idcache removes the re-fetch during hydration; the HttpClient transfer cache does the same for requests that do rideHttpClient.
One smaller change that will find you: provideServerRendering now caps responses at 1 MB by default with the fetch backend. Go over it and you get error NG02825. Adjust with maxResponseBodySize. - Source: SSR guide
Migration checklist v21 → v22
From the changelog and the update guide:
ng update @angular/core@22 @angular/cli@22. Nothing else needed to enable incremental hydration if you already hadprovideClientHydration().- Delete
withIncrementalHydration()if you used it. Deprecated, leaving in v24. - Delete
withEventReplay()if you had it. Redundant now. - Add
idto theresource()/rxResource()calls you want cached in SSR. Remember the per-user data warning. - If you serve responses over 1 MB in SSR, configure
maxResponseBodySizeonprovideServerRendering.
Production tips
- Start with the heavy components at the bottom. Maps, charts, editors: they load the most JS and users rarely touch them on landing.
hydrate on idleorhydrate on viewportand go measure. - Interaction triggers are for components users can click before hydration. A "Load more" button is the perfect candidate: the click gets queued and replayed.
- One
idper resource, documented. Six months from now nobody will remember why that string is'user-unique-id'. A three-word comment saves a three-hour investigation. - Per-user data, no
id. The TransferState ends up in the HTML. A shopping cart cached in shared HTML is a support ticket waiting to be born. - Measure FID and CLS before and after. The documented benefits are qualitative; your numbers are the ones that count.
If you haven't read how this fits into the full rendering picture, start with CSR, SSR and SSG in Angular and come back. And for the rest of the release, there's my Angular 22 overview.
How many kilobytes of JS would hydrating-on-demand save on your heaviest page? Tell me in the comments.