~12 min de lecture
Hydratation incrémentale Angular 20+ : arrête d'hydrater le DOM que ton utilisateur ne touchera pas
Tu as suivi le playbook SSR à la lettre : provideClientHydration(), withEventReplay() (Angular enregistre les événements survenus avant l'hydratation et les rejoue une fois les listeners posés, pour ne pas perdre le premier clic), un NgOptimizedImage sur ton LCP (Largest Contentful Paint, le moment où le plus gros élément visible de ta page finit de s'afficher), un @defer par-ci par-là. Et pourtant, l'INP (Interaction to Next Paint, le délai perçu entre un clic et la mise à jour de l'écran) de tes pages en prod reste au-dessus de 200ms. Lighthouse te répond "Reduce JavaScript execution time" comme un disque rayé, tu ouvres ton bundle, tu ne vois rien d'évident à virer.
Le vrai coupable : le problème n'est pas ce que tu charges, c'est quand tu l'hydrates.
Le problème : hydratation eager, coût sur toute la page
Par défaut, quand Angular passe du HTML SSR à l'app cliente, il hydrate tout l'arbre de composants d'un coup. Chaque composant instancie ses services, résout ses inputs, attache ses listeners, exécute ses effect(). Sur une page produit typique (hero, gallery, description, avis, FAQ, footer, chat widget), ce sont des dizaines de composants qui s'initialisent en série sur le main thread avant même que l'utilisateur ait pu cliquer.
Le résultat mesurable :
- INP du premier clic élevé, parce que le thread est occupé à hydrater le footer pendant que l'utilisateur essaie de cliquer sur "Ajouter au panier".
- TBT (Total Blocking Time : pour chaque tâche de plus de 50ms entre le First Contentful Paint, le premier moment où du contenu apparaît à l'écran, et le moment où la page redevient réactive, la somme du temps qui dépasse ces 50ms) qui gonfle avec la taille de l'arbre.
- CPU dépensé pour hydrater des composants que 90% des visiteurs ne toucheront jamais (footer, mentions légales, widget de chat, section FAQ).
Angular v19 a introduit l'hydratation incrémentale en developer preview. Elle est passée stable en v20, et elle est activée par défaut en v22. C'est ce qui te manque, ou ce que tu as déjà sans le savoir.
@defer (hydrate on ...) : à ne pas confondre avec @defer classique
Attention piège de sémantique. En Angular v17 tu as @defer qui remplace le composant par un placeholder côté SSR et le charge à la demande côté client. C'est du lazy loading côté rendu.
@defer (hydrate on ...), c'est l'inverse :
- Côté serveur : Angular rend le contenu entièrement, comme sans
@defer. Le HTML est présent, indexable, visible dès le premier paint. - Côté client : le code du composant est chargé et hydraté à la demande selon le trigger. Tant que le trigger ne s'est pas déclenché, aucun code de ton composant ne s'exécute sur ce sous-arbre : ni
effect(), ni listener applicatif, ni abonnement (les triggersinteraction/hoverposent une écoute minimale pour détecter le déclencheur, mais rien de plus).
Tu obtiens le meilleur des deux mondes : un HTML complet pour le paint et les crawlers, un travail d'hydratation étalé dans le temps.
Setup : ça dépend de ta version
Avant Angular v22, opt-in explicite via withIncrementalHydration() :
import { ApplicationConfig } from '@angular/core';
import {
provideClientHydration,
withIncrementalHydration,
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withIncrementalHydration()),
],
};
Note : withIncrementalHydration() inclut déjà withEventReplay() en interne. Si tu vois les deux ensemble dans ton provideClientHydration(), c'est redondant (inoffensif, mais à nettoyer).
À partir d'Angular v22, l'hydratation incrémentale est active par défaut dès que tu appelles provideClientHydration(), et withIncrementalHydration() est déprécié (retrait prévu en v24). Sur un projet neuf en v22+, tu n'as donc rien à ajouter côté config, il te reste juste à annoter tes blocs @defer (hydrate on ...) (et si tu dois désactiver le mécanisme pour un sous-arbre entier, withNoIncrementalHydration() est la sortie de secours dédiée).
Les triggers disponibles
Tous s'écrivent après le mot-clé hydrate (seul hydrate never n'a pas besoin d'un on) :
hydrate on viewport: au moment où le bloc entre dans le viewport, viaIntersectionObserver.hydrate on interaction: au premierclickoukeydownsur le contenu du bloc.hydrate on hover: au premiermouseenter,mouseoveroufocusin.hydrate on immediate: dès que le rendu initial est terminé.hydrate on idle: quandrequestIdleCallbackse déclenche (avec fallbacksetTimeoutsi non supporté ; tu peux aussi fixer un délai maximum avechydrate on idle(500)).hydrate on timer(500ms): après un délai fixe.hydrate never: jamais, au premier chargement SSR. Voir Piège 1 plus bas, ce n'est pas aussi définitif que ça en a l'air.hydrate when <expression>: sur un booléen custom (seul le bloc@defernon hydraté le plus haut dans l'arbre écoute sa condition).
Tu peux combiner plusieurs triggers séparés par ; pour hydrater sur le premier qui se déclenche :
@defer (hydrate on viewport; hydrate on hover) {
<app-reviews [productId]="product().id" />
}
Un point que la doc officielle souligne et qu'il est facile de rater : même en hydrate on ..., un @placeholder reste utile. Il ne sert à rien au premier chargement SSR (le contenu y est déjà rendu en entier), mais il s'affiche si le bloc doit se re-rendre côté client avant que son trigger ne se déclenche à nouveau. Les exemples ci-dessous l'omettent pour rester lisibles, ajoute-le en production.
Before / after sur une page produit
Avant, une page produit standard :
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { Hero } from './hero';
import { Gallery } from './gallery';
import { Description } from './description';
import { Reviews } from './reviews';
import { Faq } from './faq';
import { Footer } from '../shared/footer';
import { ChatWidget } from '../shared/chat-widget';
@Component({
selector: 'app-product-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Hero, Gallery, Description, Reviews, Faq, Footer, ChatWidget],
template: `
<app-hero [product]="product()" />
<app-gallery [images]="product().images" />
<app-description [text]="product().description" />
<app-reviews [productId]="product().id" />
<app-faq [questions]="product().faq" />
<app-footer />
<app-chat-widget />
`,
})
export default class ProductPage {
readonly product = signal(loadProduct());
}
Tous ces composants s'hydratent immédiatement au boot du client. Le footer et le chat widget bloquent le main thread pour zéro bénéfice utilisateur si personne ne fait défiler la page jusqu'à eux, ou ne survole le chat.
Après, avec l'hydratation incrémentale :
@Component({
selector: 'app-product-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Hero, Gallery, Description, Reviews, Faq, Footer, ChatWidget],
template: `
<app-hero [product]="product()" />
<app-gallery [images]="product().images" />
@defer (hydrate on viewport) {
<app-description [text]="product().description" />
}
@defer (hydrate on viewport) {
<app-reviews [productId]="product().id" />
}
@defer (hydrate on interaction) {
<app-faq [questions]="product().faq" />
}
@defer (hydrate never) {
<app-footer />
}
@defer (hydrate on hover; hydrate on timer(4s)) {
<app-chat-widget />
}
`,
})
export default class ProductPage {
readonly product = signal(loadProduct());
}
Ce qui change :
- Hero et Gallery restent hydratés eager. Ils sont dans la zone visible sans défilement et l'utilisateur clique dessus tout de suite.
- Description et Reviews s'hydratent quand ils entrent dans le viewport. Si l'utilisateur ne fait pas défiler, zéro coût côté client.
- FAQ attend un clic pour s'hydrater. Si ta FAQ est bâtie sur des
<details>HTML natifs, elle reste ouvrable au clavier et à la souris même avant l'hydratation ; le premier clic déclenche à la fois l'ouverture et l'hydratation du bloc. - Footer n'est pas hydraté au premier chargement SSR : zéro JS exécuté pour lui au démarrage. Nuance qui compte (Piège 1) : sur une navigation client ultérieure (l'utilisateur part ailleurs puis revient sur cette page via le Router), ce bloc n'est plus concerné par l'hydratation initiale, il se comporte comme un
@defernormal avec son trigger par défaut. Ce n'est donc pas un blocage permanent, seulement une économie sur le tout premier rendu. - Chat widget attend un survol, ou 4 secondes après le rendu initial, selon ce qui vient en premier (c'est un délai fixe, pas une détection d'inactivité).
Sur une page comme celle-là, une bonne moitié des composants sort du chemin d'hydratation eager. Le gain réel sur le TBT et sur l'INP du premier clic dépend du poids de chaque composant retiré : mesure-le au profiler plutôt que de le deviner (voir plus bas).
Piège 1 : hydrate never ne fige pas le DOM pour toujours
hydrate never gèle le DOM, mais seulement pour le tout premier chargement SSR. Aucun code ne s'exécute côté client pour ce bloc au démarrage : si ton composant contient une horloge, un compteur, un signal qui change, ou même un simple [class.active] réactif, tu vois la valeur figée au moment du rendu serveur, tant que cette page n'a pas été rechargée.
La nuance qui change tout : sur un rendu client ultérieur, ce bloc n'est plus couvert par l'hydratation, il redevient un @defer comme un autre, avec son propre trigger. Il redevient donc vivant dès cette deuxième visite côté client. hydrate never ne s'applique qu'au chargement initial, pas aux rendus client suivants.
Réserve hydrate never aux composants strictement statiques sur ce premier chargement : footer avec des liens en dur, mentions légales, bloc "à propos". Dès qu'il y a une notion de temps réel ou de réactivité qui doit rester juste dès cette première vue, ce n'est plus pour toi.
Bonus : un <a href> qui constitue lui-même la racine du bloc continue de fonctionner tel quel au premier chargement (c'est du HTML natif, Angular n'y attache rien). Un routerLink, en revanche, a besoin du Router côté client pour intercepter le clic : dans un bloc hydrate never, il se comporte comme un lien classique (rechargement complet de la page), pas comme une navigation SPA.
Piège 2 : les output() distinguent événement utilisateur et signal interne
Un output() émis par un enfant placé dans un bloc @defer (hydrate on ...) dépend de pourquoi il est émis.
S'il est émis en réaction à une interaction utilisateur (un clic qui remonte via un output()), l'event replay le couvre : Angular met l'interaction en file d'attente, hydrate le bloc, puis la rejoue. L'output() finit par sortir, juste avec le léger décalage du chargement.
S'il est émis par autre chose qu'une interaction, un effect(), un timer, une réponse HTTP, un resource() qui se résout, rien ne le met en file d'attente : tant que le trigger du bloc ne s'est pas déclenché, ce code ne tourne pas du tout, donc l'output() ne part jamais tout seul. C'est ce cas, pas le premier, qui casse silencieusement les patterns façade où un composant "orchestrateur" hydraté eager attend un événement qui ne vient jamais tant que personne n'a survolé ou cliqué sur l'enfant différé.
Si l'orchestrateur doit réagir à un signal qui n'est pas déclenché par une interaction utilisateur, la seule option fiable est de ne pas différer l'enfant qui l'émet : le sortir de tout bloc hydrate on ..., ou choisir un trigger qui garantit qu'il se déclenchera à temps, hydrate on immediate par exemple.
Piège 3 : @defer classique et hydrate ne sont pas interchangeables
Tu peux techniquement écrire :
@defer (on viewport) {
<app-reviews [productId]="product().id" />
} @placeholder {
<div class="min-h-[400px]"></div>
}
Et :
@defer (hydrate on viewport) {
<app-reviews [productId]="product().id" />
}
Les deux ne font pas la même chose. Le premier omet complètement app-reviews du HTML SSR : ton utilisateur voit un placeholder de 400px, les crawlers ne voient pas les avis, le composant se charge et s'affiche quand il entre dans le viewport. Le second rend intégralement app-reviews sur le serveur, l'utilisateur voit ses avis dès le paint, et seule l'hydratation attend que le bloc entre dans le viewport.
Choisis en fonction de l'impact SEO et LCP :
- Le contenu doit être crawlé et reste visible sans JS :
@defer (hydrate on viewport). - Le contenu est lourd, invisible sans JS, et n'apporte rien au premier paint :
@defer (on viewport)classique.
Le comportement complet du @defer classique (placeholder, minimum, error, triggers de chargement) est couvert en détail dans le lazy loading de composants qu'on ignore trop souvent.
Piège 4 : mesurer en dev mode ne prouve rien
Ce n'est pas qu'une histoire de bundle non minifié. Sous nx serve (HMR actif par défaut), Angular désactive l'hydratation incrémentale : dès qu'il détecte des blocs @defer avec le Hot Module Replacement actif, il charge toutes leurs dépendances immédiatement, trigger ou pas, et t'en avertit lui-même en console. Comparer eager et incrémental en nx serve ne montre donc aucune différence, ils se comportent pareil.
Mesure uniquement sur un build de production servi en SSR : nx build --configuration=production, puis node dist/eak-lp/server/server.mjs. Ajoute un CPU throttling 4x dans Chrome DevTools Performance pour te rapprocher d'un téléphone d'entrée de gamme plutôt que de ton poste de développement.
Deux indicateurs concrets à lire, sur un profil Performance enregistré depuis le tout début du chargement :
- Total Blocking Time (TBT) : dans le résumé du profil, il doit baisser quand tu retires des composants du chemin d'hydratation eager. De combien dépend du poids réel de chacun, compare avant/après plutôt que de deviner.
- INP au premier clic : clique sur ton CTA principal juste après le paint et regarde le délai avant mise à jour visuelle. Le thread étant moins occupé, ce délai descend.
Pour un contrôle plus rigoureux et reproductible, lance un audit Lighthouse en profil réseau et CPU mobile avant et après ; le détail TBT y est déjà calculé pour toi.
Piège 5 : un lien enveloppé dans hydrate on interaction
Un @defer (hydrate on interaction) s'hydrate au premier click ou keydown qui remonte depuis son sous-arbre. Le détail qui compte : Angular pose son interception sur les nœuds racine du bloc.
Si le <a> est lui-même un nœud racine du bloc, Angular intercepte le clic et empêche la navigation par défaut avant de déclencher l'hydratation puis de rejouer l'événement, donc rien ne se passe tant que le composant n'est pas chargé.
Si le <a> est imbriqué sous un autre élément qui, lui, est le nœud racine du bloc, ce cas particulier ne s'applique pas : le navigateur traite le clic sur le lien nativement, et la navigation part avant même que l'hydratation ait eu la moindre chance de se déclencher.
Dans les deux cas, ce n'est pas le comportement d'un lien de navigation normal. Pour un simple lien, hydrate never (voir Piège 1) évite l'ambiguïté et le coût d'un aller-retour d'hydratation qui ne sert à rien avant de quitter la page.
Récap actionnable
- Si tu es sur Angular v22+, tu n'as rien à changer côté config :
provideClientHydration()active déjà l'hydratation incrémentale. Si tu es sur v20 ou v21, ajoutewithIncrementalHydration()(et retirewithEventReplay()s'il est encore là, il est inclus). - Enveloppe ton footer et tes mentions légales dans
@defer (hydrate never). Gain sur le tout premier chargement, à condition qu'ils restent strictement statiques (Piège 1). - Passe avis et sections sous la ligne de flottaison en
@defer (hydrate on viewport). - Passe FAQ, menus contextuels, dropdowns en
@defer (hydrate on interaction), et le chat widget en@defer (hydrate on hover; hydrate on timer(...)). - Garde eager hero et premier CTA : c'est ce que l'utilisateur touche en premier, ne retarde pas leur hydratation pour économiser un travail que tu vas de toute façon devoir faire tout de suite après.
- Combine avec
@deferclassique pour les gros composants lourds, invisibles sans JS et sous la ligne de flottaison (map interactive, éditeur riche) : là tu retires du bundle et du travail d'hydratation. - Mesure sur un build de prod servi en SSR, jamais en
nx serve: le mode dev désactive purement et simplement l'hydratation incrémentale (Piège 4). hydrate neverne fige le DOM que pour le tout premier chargement SSR, pas pour toujours (Piège 1) : réserve-le au contenu qui doit rester statique sur cette première vue.
L'hydratation incrémentale ne remplace pas @defer classique, elle le complète. @defer retire du code du bundle initial, @defer (hydrate ...) retire du travail du main thread au boot. Les deux se combinent, et sur une page bien découpée, ce sont ces millisecondes de TBT que tu croyais impossibles à faire baisser qui finissent par bouger.