Une fuite de mémoire dans une application Angular fait rarement planter quoi que ce soit. L’onglet devient juste plus lent et plus lourd au fil de l’après-midi : chaque fois qu’un composant est détruit, quelque chose qu’il a lancé continue de tourner — un abonnement, un timer, un listener sur window — et garde tout le composant en vie avec lui. Ce sont les utilisateurs qui laissent l’application ouverte toute la journée qui paient.
Cette page montre les sources derrière presque toutes les fuites, comment fermer chacune d’elles et comment retrouver celles qui vous ont échappé avec Chrome DevTools. Le labo crée de vraies fuites dans cet onglet et les nettoie quand vous quittez la page.
Les suspects habituels
Ce widget a l’air inoffensif et contient les trois fuites classiques. Chaque callback capture this, et chacun est retenu par quelque chose qui vit plus longtemps que le widget : un service root, la liste des timers du navigateur, window. Ouvrez-le et fermez-le quelques fois dans le labo et regardez ce qui reste.
@Component({ selector: 'app-price-widget', template: `{{ price() }}` })
export class PriceWidget implements OnInit {
private readonly prices = inject(PriceFeed); // un service root : vit aussi longtemps que l’application
protected readonly price = signal(0);
ngOnInit() {
// ✗ 1. le flux garde le callback, le callback garde `this`, pour toujours
this.prices.updates$.subscribe((p) => this.price.set(p));
// ✗ 2. le timer continue de tourner après la disparition du composant
setInterval(() => this.refresh(), 5000);
// ✗ 3. window survit à tous les composants
window.addEventListener('resize', () => this.layout());
}
}Aucun widget sur la page.
- détruits, encore en vie
- 0
- mémoire retenue
- 0.00 MB
- travail des zombies
- 0/s
- abonnements
- 0
- timers
- 0
- listeners
- 0
- widgets créés
- 0
Chaque widget garde un buffer de 256 Ko, donc le nombre « retenu » est de la mémoire qui ne peut vraiment pas être libérée tant que son zombie vit. Les cases s’appliquent aux widgets créés après les avoir cochées ; « Nettoyer les zombies » arrête ce que les anciens ont laissé derrière eux.
Fermer chaque source
La règle est courte : pour tout ce que vous lancez, enregistrez un arrêt au moment même où vous le lancez. Pour les observables, Angular a deux outils qui le font pour vous : toSignal() et takeUntilDestroyed() ; pour le reste, il y a DestroyRef.onDestroy(). Les listeners déclarés dans host sont retirés par Angular ; pour plusieurs listeners sur des objets globaux, un AbortController les ferme tous en une ligne.
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-price-widget',
template: `{{ price() }}`,
host: { '(window:resize)': 'layout()' }, // ✓ 3. Angular retire lui-même les host listeners
})
export class PriceWidget {
private readonly prices = inject(PriceFeed);
private readonly destroyRef = inject(DestroyRef);
// ✓ 1a. toSignal se désabonne quand le composant est détruit
protected readonly price = toSignal(this.prices.updates$, { initialValue: 0 });
constructor() {
// ✓ 1b. ou, quand vous avez vraiment besoin de subscribe() : takeUntilDestroyed
this.prices.alerts$.pipe(takeUntilDestroyed()).subscribe((a) => this.notify(a));
// ✓ 2. chaque timer a son « stop » enregistré à côté de son « start »
const id = setInterval(() => this.refresh(), 5000);
this.destroyRef.onDestroy(() => clearInterval(id));
}
}| Source | Ce qui la garde en vie | Le remède |
|---|---|---|
subscribe() | la source garde votre callback, le callback garde le composant | toSignal(), le pipe async ou takeUntilDestroyed() |
setInterval | le navigateur garde le callback jusqu’à ce que le timer soit effacé | DestroyRef.onDestroy(() => clearInterval(id)) |
addEventListener | window et document survivent à tous les composants | host: { '(window:resize)': … }, Renderer2.listen ou un AbortController |
| caches dans les services | un service root vit aussi longtemps que l’application, et sa Map aussi | supprimer l’entrée au destroy, ou utiliser une WeakMap indexée par l’élément |
| widgets tiers | les graphiques, cartes et éditeurs enregistrent leurs propres listeners et timers | appeler leur destroy() / remove() dans onDestroy |
// ✗ un service root qui retient des composants (ou leur DOM) les garde en vie
@Service()
export class Tooltips {
private readonly open = new Map<string, TooltipComponent>(); // jamais supprimé → fuite
}
// ✓ supprimer au destroy, ou laisser le garbage collector décider avec une WeakMap
@Service()
export class Tooltips {
private readonly anchors = new WeakMap<HTMLElement, TooltipComponent>(); // clé disparue → entrée disparue
}Ce qui ne fuit pas
Tous les subscribe() ne posent pas problème. Les requêtes HttpClient se terminent d’elles-mêmes après la réponse (avec takeUntilDestroyed(), une requête lente est aussi annulée quand vous partez). Le pipe async, toSignal(), output() et les host listeners nettoient derrière eux, tout comme effect() et computed() créés dans un composant. Le Router nettoie les observables d’ActivatedRoute. Ce qui fuit, c’est un abonnement à quelque chose qui ne se termine jamais et vit plus longtemps que vous : un store, un Subject dans un service root, interval, fromEvent(window, …), les événements du router.
Trouver les fuites avec Chrome DevTools
Une fuite, c’est un nombre qui augmente alors qu’il devrait rester stable. La méthode : répéter une action qui ne devrait rien laisser derrière elle — ouvrir et fermer un dialogue, aller sur une page et revenir — et comparer la mémoire avant et après.
- Dans le laboratoire ci-dessus, décochez les trois correctifs. Ouvrez DevTools → le panneau Memory → sous Select profiling type, choisissez Heap snapshot. Cliquez sur l’icône de corbeille (Collect garbage), puis sur Take snapshot.
- Appuyez sur Ouvrir et fermer 10 fois et attendez la fin. De nouveau Collect garbage et Take snapshot.
- Snapshot 2 sélectionné, passez le menu en haut à gauche de Summary à Comparison (par rapport à Snapshot 1) et triez par # Delta.
- Tapez
app-leaky-widgetdans Filter by class : +10, un élément hôte par widget fermé. PuisDOMTimer(+10 timers qui tournent encore) etEventListener(+10 listeners). Sur le site publié, les noms de classes sont minifiés :LeakyWidgetn’apparaît que si vous lancez l’application en développement. - Cliquez sur une instance et lisez le volet Retainers en bas : dépliez les lignes pour remonter la chaîne jusqu’à ce qui la garde en vie. Appuyez sur Nettoyer les zombies, prenez un troisième snapshot, et les mêmes lignes apparaissent sous # Deleted.
- Plus direct : sous Select profiling type, choisissez Detached elements et appuyez sur Get detached elements. Vous obtenez un
<app-leaky-widget>par widget fermé.
Pour une vérification rapide sans snapshot : menu de commandes (Ctrl/Cmd+Shift+P) → Show Performance monitor. Surveillez JS event listeners : il augmente de 10 à chaque « 10 fois » correctifs décochés et ne redescend qu’après le nettoyage.
Tous les panneaux, pas à pas, avec exercices et pièges (par exemple DevTools qui garde lui-même des objets en vie), sont dans DevTools : regarder dans votre application.
Un test qui attrape la fuite la prochaine fois
Une fois une fuite corrigée, rendez son retour impossible. Un Subject sait si quelqu’un est encore abonné (observed) ; après fixture.destroy(), la réponse doit être false. Plus de détails sur les tests dans tester avec Vitest.
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
describe('PriceWidget', () => {
it('stops listening to the feed when destroyed', () => {
const feed = TestBed.inject(PriceFeed); // updates$ est un Subject dans le service
const fixture = TestBed.createComponent(PriceWidget);
fixture.detectChanges();
expect(feed.updates.observed).toBe(true);
fixture.destroy();
expect(feed.updates.observed).toBe(false); // plus personne n’écoute
});
});Quand OUI, quand NON
- Les onglets qui restent ouverts des heures : tableaux de bord, outils de back-office, caisses, chat.
- Les composants créés et détruits souvent : dialogues, lignes de liste, onglets, routes visitées à répétition.
- Le code qui s’abonne à des choses globales : stores, WebSockets, événements window, événements du router.
- Une requête HttpClient ponctuelle : elle se termine d’elle-même.
- Les abonnements via le pipe async ou toSignal() : ils sont nettoyés pour vous.
- Un service root abonné à quelque chose pendant toute la vie de l’application : c’est voulu, pas une fuite.
Pièges fréquents
subscribe()dansngOnInit, sans teardown, à quelque chose venant d’un service root.- Faire confiance à
take(1). Si la source n’émet jamais — l’utilisateur part avant la première valeur —, l’abonnement attend pour toujours. removeEventListeneravec une autre fonction..bind(this)crée une nouvelle fonction à chaque fois, donc rien n’est retiré.- Des composants ou des nœuds DOM dans une
Mapde service. Le service vit pour toujours, et eux aussi. - Mesurer sans lancer le garbage collector d’abord. Le heap monte et descend tout seul ; comparez des snapshots pris après Collect garbage.
Aide-mémoire
subscribe() à une source durable takeUntilDestroyed() · toSignal() · pipe async
take(1) / first() sûr seulement si la source VA émettre ; sinon il attend pour toujours
setInterval / chaînes de setTimeout DestroyRef.onDestroy(() => clearInterval(id))
listeners sur window / document host: { '(window:resize)': … } · Renderer2.listen · AbortController
removeEventListener exige la MÊME fonction : garder la référence, pas deux .bind()
services root pas de Map de composants ou de nœuds DOM ; supprimer au destroy, ou WeakMap
widgets tiers chart.destroy(), map.remove() dans onDestroy
ne fuit pas HttpClient (se termine), pipe async, toSignal, outputs, host listeners
sûrs aussi effect() et computed() dans les composants : détruits avec eux
DevTools Memory → snapshot → répéter N× → GC → snapshot → Comparison
lire les retainers Subscriber / Timeout / EventListener = qui le garde en vie
"Detached" des nœuds DOM retirés de la page mais encore référencés
en CI subject.observed === false après fixture.destroy()