Ein Memory Leak in einer Angular-App bringt selten etwas zum Absturz. Der Tab wird im Laufe eines Nachmittags nur langsamer und schwerer: Jedes Mal, wenn eine Komponente zerstört wird, läuft etwas, das sie gestartet hat, weiter — eine Subscription, ein Timer, ein Listener auf window — und hält die ganze Komponente mit am Leben. Bezahlen müssen die Nutzer, die die App den ganzen Tag offen lassen.
Diese Seite zeigt die Quellen hinter fast jedem Leak, wie du jede einzelne schließt und wie du die übersehenen mit Chrome DevTools findest. Das Labor erzeugt echte Leaks in diesem Tab und räumt sie auf, wenn du die Seite verlässt.
Die üblichen Verdächtigen
Dieses Widget sieht harmlos aus und enthält die drei klassischen Leaks. Jeder Callback fängt this ein, und jeder wird von etwas gehalten, das länger lebt als das Widget: einem Root-Service, der Timer-Liste des Browsers, window. Öffne und schließe es im Labor ein paar Mal und beobachte, was zurückbleibt.
@Component({ selector: 'app-price-widget', template: `{{ price() }}` })
export class PriceWidget implements OnInit {
private readonly prices = inject(PriceFeed); // ein Root-Service: lebt so lange wie die App
protected readonly price = signal(0);
ngOnInit() {
// ✗ 1. der Feed hält den Callback, der Callback hält `this`, für immer
this.prices.updates$.subscribe((p) => this.price.set(p));
// ✗ 2. der Timer läuft weiter, nachdem die Komponente weg ist
setInterval(() => this.refresh(), 5000);
// ✗ 3. window überlebt jede Komponente
window.addEventListener('resize', () => this.layout());
}
}Kein Widget auf der Seite.
- zerstört, noch am Leben
- 0
- gehaltener Speicher
- 0.00 MB
- Zombie-Arbeit
- 0/s
- Subscriptions
- 0
- Timer
- 0
- Listener
- 0
- erstellte Widgets
- 0
Jedes Widget hält einen 256-KB-Puffer, die Zahl „gehalten“ ist also Speicher, der wirklich nicht freigegeben werden kann, solange sein Zombie lebt. Die Kästchen gelten für Widgets, die nach dem Anhaken erstellt werden; „Zombies aufräumen“ stoppt, was die alten hinterlassen haben.
Jede Quelle schließen
Die Regel ist kurz: Für alles, was du startest, registrierst du im selben Moment ein Stopp. Für Observables hat Angular zwei Werkzeuge, die das für dich erledigen: toSignal() und takeUntilDestroyed(); für den Rest gibt es DestroyRef.onDestroy(). In host deklarierte Listener entfernt Angular; für mehrere Listener auf globalen Objekten schließt ein AbortController alle in einer Zeile.
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 entfernt Host-Listener selbst
})
export class PriceWidget {
private readonly prices = inject(PriceFeed);
private readonly destroyRef = inject(DestroyRef);
// ✓ 1a. toSignal meldet sich ab, wenn die Komponente zerstört wird
protected readonly price = toSignal(this.prices.updates$, { initialValue: 0 });
constructor() {
// ✓ 1b. oder, wenn du wirklich subscribe() brauchst: takeUntilDestroyed
this.prices.alerts$.pipe(takeUntilDestroyed()).subscribe((a) => this.notify(a));
// ✓ 2. jeder Timer bekommt sein „Stopp“ direkt neben seinem „Start“ registriert
const id = setInterval(() => this.refresh(), 5000);
this.destroyRef.onDestroy(() => clearInterval(id));
}
}| Quelle | Was sie am Leben hält | Die Lösung |
|---|---|---|
subscribe() | die Quelle hält deinen Callback, der Callback hält die Komponente | toSignal(), die async-Pipe oder takeUntilDestroyed() |
setInterval | der Browser hält den Callback, bis der Timer gelöscht wird | DestroyRef.onDestroy(() => clearInterval(id)) |
addEventListener | window und document überleben jede Komponente | host: { '(window:resize)': … }, Renderer2.listen oder ein AbortController |
| Caches in Services | ein Root-Service lebt so lange wie die App, und seine Map auch | den Eintrag beim Destroy löschen oder eine WeakMap mit dem Element als Schlüssel nutzen |
| Widgets von Drittanbietern | Charts, Karten und Editoren registrieren eigene Listener und Timer | ihr destroy() / remove() in onDestroy aufrufen |
// ✗ ein Root-Service, der sich Komponenten (oder ihr DOM) merkt, hält sie am Leben
@Service()
export class Tooltips {
private readonly open = new Map<string, TooltipComponent>(); // nie gelöscht → Leak
}
// ✓ beim Destroy löschen oder den Garbage Collector per WeakMap entscheiden lassen
@Service()
export class Tooltips {
private readonly anchors = new WeakMap<HTMLElement, TooltipComponent>(); // Schlüssel weg → Eintrag weg
}Was nicht leakt
Nicht jedes subscribe() ist ein Problem. HttpClient-Anfragen completen nach der Antwort von selbst (mit takeUntilDestroyed() wird eine langsame Anfrage beim Verlassen auch abgebrochen). Die async-Pipe, toSignal(), output() und Host-Listener räumen hinter sich auf, ebenso effect() und computed(), die in einer Komponente erstellt wurden. Der Router räumt die Observables von ActivatedRoute auf. Was leakt, ist eine Subscription auf etwas, das nie completet und länger lebt als du: ein Store, ein Subject in einem Root-Service, interval, fromEvent(window, …), Router-Events.
Leaks mit Chrome DevTools finden
Ein Leak ist eine Zahl, die wächst, obwohl sie gleich bleiben sollte. Die Methode: Wiederhole eine Aktion, die nichts zurücklassen sollte — einen Dialog öffnen und schließen, auf eine Seite und zurück gehen —, und vergleiche den Speicher davor und danach.
- Wähle im Labor oben die drei Fixes ab. Öffne DevTools → das Panel Memory → unter Select profiling type Heap snapshot. Klicke auf das Papierkorb-Symbol (Collect garbage), dann auf Take snapshot.
- Drücke 10-mal öffnen und schließen und warte, bis es fertig ist. Wieder Collect garbage und Take snapshot.
- Stelle bei ausgewähltem Snapshot 2 das Menü oben links von Summary auf Comparison (gegen Snapshot 1) und sortiere nach # Delta.
- Tippe
app-leaky-widgetin Filter by class: +10, ein Host-Element pro geschlossenem Widget. DannDOMTimer(+10 noch laufende Timer) undEventListener(+10 Listener). Auf der veröffentlichten Website sind Klassennamen minifiziert,LeakyWidgeterscheint also nur, wenn du die App in der Entwicklung startest. - Klicke auf eine Instanz und lies den Bereich Retainers unten: Klappe die Zeilen auf, um die Kette bis zu dem hinaufzugehen, was sie am Leben hält. Drücke Zombies aufräumen, nimm einen dritten Snapshot auf, und dieselben Zeilen stehen unter # Deleted.
- Direkter: Wähle unter Select profiling type Detached elements und drücke Get detached elements. Du bekommst ein
<app-leaky-widget>pro geschlossenem Widget.
Für einen schnellen Check ohne Snapshots: Befehlsmenü (Ctrl/Cmd+Shift+P) → Show Performance monitor. Beobachte JS event listeners: Der Wert steigt bei jedem „10-mal“ mit abgewählten Fixes um 10 und sinkt erst nach dem Aufräumen.
Alle Panels Schritt für Schritt, mit Übungen und Stolperfallen (etwa DevTools, das selbst Objekte am Leben hält), findest du unter DevTools: ein Blick in deine App.
Ein Test, der den Leak beim nächsten Mal fängt
Hast du einen Leak behoben, mach es unmöglich, ihn zurückzubringen. Ein Subject weiß, ob noch jemand abonniert ist (observed); nach fixture.destroy() muss die Antwort false sein. Mehr zu Tests unter Testen mit 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$ ist ein Subject im Service
const fixture = TestBed.createComponent(PriceWidget);
fixture.detectChanges();
expect(feed.updates.observed).toBe(true);
fixture.destroy();
expect(feed.updates.observed).toBe(false); // niemand hört mehr zu
});
});Wann ja, wann nein
- Tabs, die stundenlang offen bleiben: Dashboards, Backoffice-Tools, Kassen, Chat.
- Komponenten, die oft erstellt und zerstört werden: Dialoge, Listenzeilen, Tabs, wiederholt besuchte Routen.
- Code, der globale Dinge abonniert: Stores, WebSockets, window-Events, Router-Events.
- Eine einmalige HttpClient-Anfrage: Sie completet von selbst.
- Subscriptions über die async-Pipe oder toSignal(): Sie werden für dich aufgeräumt.
- Ein Root-Service, der für die ganze Lebensdauer der App etwas abonniert: Das ist Absicht, kein Leak.
Häufige Stolperfallen
subscribe()inngOnInit, ohne Teardown, auf etwas aus einem Root-Service.take(1)vertrauen. Emittiert die Quelle nie — der Nutzer geht vor dem ersten Wert —, wartet die Subscription ewig.removeEventListenermit einer anderen Funktion..bind(this)erzeugt jedes Mal eine neue Funktion, also wird nichts entfernt.- Komponenten oder DOM-Knoten in einer Service-
Map. Der Service lebt ewig, und sie mit ihm. - Messen, ohne vorher Garbage Collection auszulösen. Der Heap steigt und fällt von selbst; vergleiche Snapshots, die nach Collect garbage aufgenommen wurden.
Spickzettel
subscribe() auf langlebige Quelle takeUntilDestroyed() · toSignal() · async-Pipe
take(1) / first() nur sicher, wenn die Quelle emittieren WIRD; sonst wartet es ewig
setInterval / setTimeout-Ketten DestroyRef.onDestroy(() => clearInterval(id))
Listener auf window / document host: { '(window:resize)': … } · Renderer2.listen · AbortController
removeEventListener braucht DIESELBE Funktion: Referenz behalten, nicht zweimal .bind()
Root-Services keine Map von Komponenten oder DOM-Knoten; beim Destroy löschen oder WeakMap
Widgets von Drittanbietern chart.destroy(), map.remove() in onDestroy
leakt nicht HttpClient (completet), async-Pipe, toSignal, Outputs, Host-Listener
auch sicher effect() und computed() in Komponenten: werden mit ihnen zerstört
DevTools Memory → Snapshot → N× wiederholen → GC → Snapshot → Comparison
Retainers lesen Subscriber / Timeout / EventListener = wer es am Leben hält
"Detached" DOM-Knoten, die aus der Seite entfernt, aber noch referenziert sind
in CI subject.observed === false nach fixture.destroy()