Performance
OnPush, zoneless, @defer, SSR et hydratation.
Vérifié en septembre 2026 · Angular 22.1 · environ 13 min
Une application Angular paie deux coûts. Au chargement, le JavaScript à télécharger et à exécuter avant que la page s'affiche et réponde ; à l'usage, le travail que chaque interaction déclenche. Angular 22 règle une bonne part du second par ses défauts : OnPush et le zoneless. Le premier se règle par le découpage du code, avec @defer, par les images et par le rendu serveur. Ce cours suit ces leviers, après la règle qui les précède tous : mesurer d'abord.
Mesurer avant d'optimiser
Une optimisation sans mesure déplace le code sans savoir si elle a gagné quoi que ce soit. Trois instruments couvrent l'essentiel. L'extension de navigateur Angular DevTools a un onglet Profiler qui enregistre chaque cycle de détection de changements : sa durée, le temps passé dans chaque composant, et, avec « Show only change detection », les composants qui n'ont pas été vérifiés, en gris. Le panneau Performance de Chrome montre la même chose dans le contexte du navigateur (réseau, rendu, tâches longues) une fois enableProfiling() appelé, public depuis Angular 21 : une piste Angular colore en bleu le code écrit par l'équipe, en violet les templates compilés, en vert ce qui a déclenché l'exécution.
// main.ts
import { enableProfiling } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
// Avant le demarrage, pour que le premier rendu figure aussi dans
// l'enregistrement. En production, l'appel ne fait rien : la piste Angular du
// panneau Performance n'existe qu'en mode developpement.
enableProfiling();
bootstrapApplication(App, appConfig).catch((erreur) => console.error(erreur)); Pour le chargement, ng build est déjà un instrument : il liste les Initial chunk files, ce que le navigateur télécharge avant d'afficher quoi que ce soit, et les Lazy chunk files, avec leur taille brute et une estimation de leur taille transférée. ng build --stats-json écrit en plus un stats.json qu'accepte l'analyseur d'esbuild. Les budgets de angular.json transforment ces chiffres en garde-fou : ceux de ce site font avertir le build au-delà de 500 kB de bundle initial et échouer au-delà de 1 MB.
{
"projects": {
"web": {
"architect": {
"build": {
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
]
}
}
}
}
}
}
}OnPush : ce qui marque une vue
Depuis Angular 22.0, sorti le 3 juin 2026, un composant qui n'écrit pas de changeDetection est OnPush ; l'ancienne stratégie, nommée Eager depuis la 21.2, s'écrit explicitement (Default, son ancien nom, reste un alias déprécié). Un composant OnPush n'est vérifié que si sa vue est marquée, et quatre choses la marquent :
- une entrée qui reçoit une valeur différente, comparée par référence ;
- un événement lié dans son template ou sur son hôte, qui marque la vue et tous ses ancêtres jusqu'à la racine, mais aucun de ses enfants ;
- un signal lu par son template qui change : seule cette vue est relue, ses ancêtres sont traversés sans l'être (le cours Signals le montre) ;
markForCheck(), qu'appelle le pipeasync.
L'entrée est la règle qu'on enfreint sans le voir. Muter un tableau passé en entrée ne change pas sa référence : le parent est relu, l'enfant non, et l'écran se contredit lui-même.
import { Component, input, signal } from '@angular/core';
// Aucun changeDetection ecrit : en Angular 22, les deux composants sont OnPush.
@Component({
selector: 'app-resume-panier',
template: `<p>{{ articles().length }} article(s) dans le resume</p>`,
})
export class ResumePanier {
readonly articles = input.required<string[]>();
}
@Component({
selector: 'app-panier',
imports: [ResumePanier],
template: `
<p>{{ articles().length }} article(s) dans le panier</p>
<app-resume-panier [articles]="articles()" />
<button type="button" (click)="ajouter('HDMI-2')">Ajouter</button>
`,
})
export class Panier {
protected readonly articles = signal<string[]>(['USB-64']);
protected ajouter(reference: string): void {
// Le clic marque Panier et ses ancetres : son template est relu. Le resume
// recoit le meme tableau, l'entree n'a pas change, sa vue n'est pas marquee.
this.articles().push(reference);
}
}
// Apres un clic : « 2 article(s) dans le panier », « 1 article(s) dans le resume ».import { Component, input, signal } from '@angular/core';
@Component({
selector: 'app-resume-panier',
template: `<p>{{ articles().length }} article(s) dans le resume</p>`,
})
export class ResumePanier {
// readonly dans le type : un push ne compile plus.
readonly articles = input.required<readonly string[]>();
}
@Component({
selector: 'app-panier',
imports: [ResumePanier],
template: `
<p>{{ articles().length }} article(s) dans le panier</p>
<app-resume-panier [articles]="articles()" />
<button type="button" (click)="ajouter('HDMI-2')">Ajouter</button>
`,
})
export class Panier {
protected readonly articles = signal<readonly string[]>(['USB-64']);
protected ajouter(reference: string): void {
// Un nouveau tableau : l'entree du resume change, sa vue est marquee.
this.articles.update((articles) => [...articles, reference]);
}
}
// Apres un clic : 2 et 2. Le gain d'OnPush vient précisément de là : un sous-arbre dont rien n'a changé n'est pas parcouru. La contrepartie est l'immuabilité des données qui traversent les entrées, que le type readonly fait vérifier par le compilateur.
Zoneless : seules les notifications comptent
Depuis Angular 21, sorti le 19 novembre 2025, une application démarre sans Zone.js : bootstrapApplication installe lui-même le planificateur zoneless, et seul provideZoneChangeDetection() ramène l'ancien fonctionnement. Ce site écrit malgré tout provideZonelessChangeDetection() dans son app.config.ts, pour le dire, et n'a pas de dépendance zone.js. Une détection de changements n'est plus planifiée qu'après une notification ; le cours Signals en donne la liste et montre ce que cela casse dans un composant qui comptait sur Zone.js. Ici, c'est l'autre face : ce que cela coûte.
NgZone devient un objet inerte : runOutsideAngular exécute sa fonction et rien d'autre, onStable n'émet jamais. Sous Zone.js, c'était l'outil pour qu'un mousemove ne relance pas toute l'application ; en zoneless, l'écouteur lié dans le template est lui-même la notification.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-carte',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p (mousemove)="suivre($event)">Zone : {{ zone() }}</p>`,
})
export class Carte {
protected readonly zone = signal<'gauche' | 'droite'>('gauche');
// Un ecouteur lie dans le template est une notification : chaque mousemove
// marque Carte et tous ses ancetres, puis planifie une detection de
// changements. Cinquante mouvements du meme cote : cinquante passages, qui
// relisent le template de Carte et celui de chaque ancetre, alors que zone
// n'a pas change une seule fois.
protected suivre(evenement: MouseEvent): void {
this.zone.set(evenement.clientX < window.innerWidth / 2 ? 'gauche' : 'droite');
}
}import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
afterNextRender,
inject,
signal,
} from '@angular/core';
@Component({
selector: 'app-carte',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>Zone : {{ zone() }}</p>`,
})
export class Carte {
protected readonly zone = signal<'gauche' | 'droite'>('gauche');
constructor() {
const hote = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
const destroyRef = inject(DestroyRef);
// Un addEventListener ordinaire ne previent pas Angular. Seule l'ecriture
// d'une valeur differente dans zone notifie, et elle ne marque que Carte.
// afterNextRender ne s'execute que dans le navigateur, jamais au rendu serveur.
afterNextRender(() => {
const suivre = (evenement: MouseEvent) =>
this.zone.set(evenement.clientX < window.innerWidth / 2 ? 'gauche' : 'droite');
hote.addEventListener('mousemove', suivre);
destroyRef.onDestroy(() => hote.removeEventListener('mousemove', suivre));
});
}
}
// Cinquante mouvements, dont un seul passe a droite : Carte est relue une fois,
// ses ancetres jamais. La correction tient à ce qu'un addEventListener posé à la main ne prévient pas Angular, et qu'un signal ne notifie que si sa valeur change. Seul le passage de gauche à droite coûte une détection, limitée à la vue qui lit zone. Le motif vaut pour tout événement fréquent : scroll, resize, pointermove.
track : le coût d'une clé
Le cours Bases montre qu'une mauvaise clé de @for laisse une saisie sur la mauvaise ligne. Elle a aussi un coût. track produit compare les objets eux-mêmes ; une liste rechargée depuis une API ne contient que des objets neufs, et Angular détruit puis recrée chaque ligne, composants et DOM compris.
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
interface Produit {
readonly reference: string;
readonly libelle: string;
}
// Ce que rendrait une API : les memes produits, en objets neufs a chaque appel.
function lireCatalogue(): Produit[] {
return Array.from({ length: 100 }, (_, i) => ({ reference: `REF-${i}`, libelle: `Produit ${i}` }));
}
@Component({
selector: 'app-fiche',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ produit().libelle }}</p>`,
})
export class Fiche {
readonly produit = input.required<Produit>();
}
@Component({
selector: 'app-catalogue',
imports: [Fiche],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (produit of produits(); track produit) {
<app-fiche [produit]="produit" />
}
<button type="button" (click)="actualiser()">Actualiser</button>
`,
})
export class Catalogue {
protected readonly produits = signal<readonly Produit[]>(lireCatalogue());
// track produit compare les objets. Apres actualiser, aucun n'est le meme :
// les 100 fiches sont detruites puis recreees, DOM compris. En developpement,
// la console le signale :
// NG0956: The configured tracking expression (track by identity) caused
// re-creation of the entire collection of size 100.
protected actualiser(): void {
this.produits.set(lireCatalogue());
}
}import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
interface Produit {
readonly reference: string;
readonly libelle: string;
}
function lireCatalogue(): Produit[] {
return Array.from({ length: 100 }, (_, i) => ({ reference: `REF-${i}`, libelle: `Produit ${i}` }));
}
@Component({
selector: 'app-fiche',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ produit().libelle }}</p>`,
})
export class Fiche {
readonly produit = input.required<Produit>();
}
// Seule la cle du @for change.
@Component({
selector: 'app-catalogue',
imports: [Fiche],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (produit of produits(); track produit.reference) {
<app-fiche [produit]="produit" />
}
<button type="button" (click)="actualiser()">Actualiser</button>
`,
})
export class Catalogue {
protected readonly produits = signal<readonly Produit[]>(lireCatalogue());
// Les references n'ont pas bouge : aucune fiche creee ni detruite. Chacune
// recoit son nouvel objet par son entree et relit son template ; le texte
// etant identique, le DOM n'est pas touche.
protected actualiser(): void {
this.produits.set(lireCatalogue());
}
} Avec l'identité métier pour clé, les cent vues sont conservées ; chacune ne fait que relire sa liaison. L'avertissement NG0956 n'apparaît qu'en mode développement, seulement pour une clé qui est l'objet lui-même, et seulement quand toute la collection a été recréée : une clé calculée mais mauvaise, comme $index, ne le déclenche jamais.
@defer : sortir du code du bundle
Le cours Routage montre comment loadComponent découpe l'application par page. @defer découpe à l'intérieur d'un template : les composants, directives et pipes utilisés dans le bloc partent dans un fichier à part, chargé quand un déclencheur le demande. Il faut pour cela qu'ils soient standalone et qu'aucune autre partie du même fichier n'y fasse référence ; sinon, ils restent chargés d'emblée, sans erreur.
import { ChangeDetectionStrategy, Component } from '@angular/core';
// Un composant standalone dans son propre fichier, qui embarque une
// bibliotheque de graphiques. Il n'est utilise nulle part hors du @defer.
import { GraphiqueVentes } from './graphique-ventes';
@Component({
selector: 'app-tableau-de-bord',
imports: [GraphiqueVentes],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h2>Ventes du mois</h2>
<!-- Rendu quand le placeholder entre dans la fenetre ; telecharge des que
le navigateur est inoccupe, pour etre pret avant. -->
@defer (on viewport; prefetch on idle) {
<app-graphique-ventes />
} @placeholder (minimum 500ms) {
<!-- Un seul element racine : c'est lui que viewport observe. -->
<div class="cadre-graphique">Graphique des ventes</div>
} @loading (after 100ms; minimum 1s) {
<p>Chargement du graphique...</p>
} @error {
<p>Le graphique n'a pas pu etre charge.</p>
}
`,
})
export class TableauDeBord {} Les déclencheurs sont on idle, le défaut quand aucun n'est écrit, on viewport, on interaction, on hover, on immediate, on timer(2s) et when suivi d'une condition, qui ne vaut qu'une fois : le bloc chargé ne revient pas en arrière. prefetch sépare le téléchargement de l'affichage. @placeholder s'affiche avant le déclenchement, pendant au moins minimum ; @loading n'apparaît que si le chargement dépasse after, puis reste au moins minimum, pour éviter un clignotement ; @error prend la place si le fichier ne se charge pas. Ce qui est visible dès l'arrivée sur la page ne se diffère pas : le contenu qui remplace le placeholder fait sauter la mise en page.
L'effet se mesure. Sur ce site, un composant qui enregistre trois grammaires de highlight.js a été placé dans un cours, une fois directement, une fois sous @defer (on viewport; prefetch on idle), avec ng build en production :
| Fichier | Sans @defer | Avec @defer |
|---|---|---|
| main (initial) | 258,48 kB | 272,12 kB |
| chunk du cours | 13,33 kB | 662 octets |
| chunk différé du composant | aucun | 13,00 kB |
Le composant quitte le fichier du cours pour le sien : le chunk de la route s'allège, même si la route était déjà chargée à la demande. En contrepartie, le premier @defer d'une application fait entrer dans main la mécanique des blocs différés. Ce coût d'entrée se paie une seule fois pour toute l'application, et il dépend des déclencheurs employés : sur ce site, 10,06 kB bruts avec on immediate, 11,78 kB avec on idle, 13,64 kB avec on viewport; prefetch on idle, qui ajoute l'observateur de visibilité. En taille transférée estimée, l'écart n'est que d'environ 3,4 kB. Ce qui décide est donc le poids différé face à ce coût d'entrée, que la page se charge d'emblée ou par sa route : treize kilo-octets de grammaires le couvrent à peine, une bibliothèque de graphiques ou un éditeur le couvrent largement, et les blocs suivants réutilisent la même mécanique.
Images : NgOptimizedImage
Sur beaucoup de pages, l'image principale est le plus gros élément affiché, celui qui fixe le Largest Contentful Paint. La directive NgOptimizedImage, qui s'applique à <img ngSrc>, impose les bons réglages : chargement paresseux par défaut, priority pour l'image principale, dimensions obligatoires.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-fiche-produit',
imports: [NgOptimizedImage],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- L'image principale, probablement l'element LCP : priority la charge
tout de suite (loading="eager", fetchpriority="high"). -->
<img ngSrc="/images/casque.jpg" width="800" height="600" priority alt="Casque audio" />
<!-- Les autres restent en loading="lazy", le defaut de la directive.
width et height sont obligatoires (ou fill) : ils reservent la place
et evitent que la page saute a l'arrivee de l'image. -->
@for (vignette of vignettes(); track vignette) {
<img [ngSrc]="vignette" width="160" height="120" alt="" />
}
`,
})
export class FicheProduit {
protected readonly vignettes = signal(['/images/casque-profil.jpg', '/images/casque-etui.jpg']);
} En développement, elle signale ce qu'elle ne peut pas corriger seule : NG02954 si width ou height manque, NG02955 quand l'image LCP n'est pas marquée priority, NG02956 quand une image prioritaire vient d'une origine sans <link rel="preconnect">. Le srcset automatique, adapté à sizes quand il est donné, suppose un chargeur d'images : provideImgixLoader, provideCloudinaryLoader et leurs voisins demandent au CDN la largeur voulue ; sans chargeur, ngSrc est servi tel quel.
SSR, hydratation et relecture d'événements
Le rendu serveur envoie du HTML déjà construit : la page s'affiche avant que le JavaScript soit arrivé. L'hydratation est ce qui suit dans le navigateur : Angular démarre, retrouve dans le DOM existant les nœuds que son rendu aurait créés, et s'y attache au lieu de tout reconstruire. Le paquet @angular/ssr fournit le serveur et ng add @angular/ssr le met en place ; ce site, ouvert depuis le disque, n'en a pas, et les exemples de cette section sont compilés sans être exécutés.
// app.config.ts d'une application rendue par le serveur
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
// En Angular 22, cet appel seul active l'hydratation, l'hydratation
// incrementale et, avec elle, la relecture des evenements.
// withNoIncrementalHydration() retire la deuxieme.
provideClientHydration(),
],
}; L'hydratation incrémentale, active par défaut depuis Angular 22, ne réveille qu'une partie de la page à la fois. Elle réutilise la syntaxe de @defer avec des déclencheurs hydrate : le serveur rend le contenu du bloc, pas son placeholder, et le code du bloc n'est téléchargé qu'au déclenchement. Sans déclencheur hydrate, un @defer rendu par le serveur n'y affiche que son placeholder.
<!-- Hors de tout @defer : hydrate des le demarrage. -->
<app-entete />
<!-- Rendu par le serveur, hydrate quand il devient visible. -->
@defer (hydrate on viewport) {
<app-avis-clients />
} @placeholder {
<p>Avis clients</p>
}
<!-- Hydrate au premier clic ou a la premiere frappe ; ce premier clic,
capture avant, est rejoue une fois le code arrive. -->
@defer (hydrate on interaction) {
<app-configurateur />
} @placeholder {
<p>Configurateur</p>
}
<!-- Jamais hydrate : du HTML statique, sans le JavaScript de son composant. -->
@defer (hydrate never) {
<app-mentions-legales />
} @placeholder {
<p>Mentions legales</p>
} Les placeholders de ces blocs ne servent pas au premier affichage : ils reprennent la main lors d'une navigation dans le navigateur, où ce sont les déclencheurs ordinaires qui s'appliquent, ici on idle faute d'autre. La relecture d'événements ferme la fenêtre entre l'affichage et l'hydratation : un petit script que le serveur place dans la page capture les événements, clics ou frappes, destinés à une partie encore inerte, puis les rejoue quand son code est prêt. Elle se déclare par withEventReplay(), mais l'hydratation incrémentale l'inclut : un provideClientHydration() nu la met donc en route en 22, et withEventReplay() ne redevient nécessaire qu'avec withNoIncrementalHydration(). L'hydratation a ses exigences : un HTML valide, que le navigateur ne réécrit pas, et pas de manipulation directe du DOM hors d'Angular. Un composant qui ne peut pas s'y plier reçoit l'attribut ngSkipHydration : il est alors reconstruit dans le navigateur au lieu d'être hydraté.