Skip to content
Angular•6 min read

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 notes
  • hydrate 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:

  1. The id must be unique within your application and identical on server and client. If they don't match, the cache misses and the loader runs anyway.
  2. 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 id on 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 @defer with hydrate behaves like a regular @defer and the @placeholder renders. 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 interaction pattern safe: the user can click before the block is alive and the click isn't lost.
  • The id cache removes the re-fetch during hydration; the HttpClient transfer cache does the same for requests that do ride HttpClient.

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:

  1. ng update @angular/core@22 @angular/cli@22. Nothing else needed to enable incremental hydration if you already had provideClientHydration().
  2. Delete withIncrementalHydration() if you used it. Deprecated, leaving in v24.
  3. Delete withEventReplay() if you had it. Redundant now.
  4. Add id to the resource()/rxResource() calls you want cached in SSR. Remember the per-user data warning.
  5. If you serve responses over 1 MB in SSR, configure maxResponseBodySize on provideServerRendering.

Production tips

  1. 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 idle or hydrate on viewport and go measure.
  2. Interaction triggers are for components users can click before hydration. A "Load more" button is the perfect candidate: the click gets queued and replayed.
  3. One id per 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.
  4. 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.
  5. 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.


> More posts