Change Detection ist der Mechanismus, mit dem Angular entscheidet, ob ein Template neu in den DOM geschrieben werden muss. Er ist das Herz des Frameworks — und gleichzeitig die Stelle, an der Apps am häufigsten Performance verlieren. Wer versteht, was Change Detection auslöst, welche Components sie durchläuft und wann sie übersprungen werden darf, schreibt schneller laufende Apps mit deutlich weniger Aufwand.
Dieser Artikel erklärt das Mental Model hinter dem Component-Tree-Walk, vergleicht Default (in v21 als Eager aliased) mit OnPush, zeigt den Übergang von Zone.js zu Zoneless und ordnet ein, warum die Kombination aus OnPush + Signals ab Angular v22 der Default-Pfad ist. Du lernst, wann markForCheck() und wann detectChanges() das richtige Werkzeug ist, und welche Stolperfallen — Mutation, ExpressionChangedAfterItHasBeenCheckedError, async-pipe-Magie — sich aus dem Modell ergeben.
Der Mechanismus hinter dem Re-Render
Angular hält intern einen Baum aus Component Views. Jede Component-Instanz hat einen eigenen Knoten mit seinem aktuellen Template-State (Properties, Bindings, Inputs). Change Detection ist der Prozess, der durch diesen Baum top-down läuft, jede Property-Bindung neu auswertet und — falls sich der Wert geändert hat — die zugehörige DOM-Stelle aktualisiert.
Wichtig zu verstehen: Change Detection ändert keinen State und löst keine Logik aus. Sie liest nur, was bereits im Komponenten-State steht, und gleicht den DOM ab. Der State selbst wurde vorher von einem Trigger geändert — das ist der nächste Punkt.
| Begriff | Bedeutung |
|---|---|
| View | Interne Repräsentation einer Component-Instanz inkl. Template-Bindings |
| CD-Tree | Baum aller Views, root = ApplicationRef |
| Dirty-Flag | Marker pro View, ob sie im nächsten Cycle geprüft werden muss |
| CD-Cycle | Ein kompletter top-down-Walk durch den Tree |
| Trigger | Ereignis, das einen Cycle anstößt (Event, Timer, Promise, Signal-Update) |
DOM-Events, Timer, Promises — und Signals
Klassisch, mit Zone.js, sind die Trigger fest verdrahtet. Zone.js patcht alle async Browser-APIs und sagt Angular nach jedem Callback: „Cycle laufen lassen.” Das sind im Wesentlichen:
- DOM-Events über
(click),(input),(submit)etc. - Timer (
setTimeout,setInterval,requestAnimationFrame) - Microtasks (
Promise.then,queueMicrotask) - XHR / fetch Callbacks
- Manuelle Aufrufe:
ApplicationRef.tick(),ChangeDetectorRef.detectChanges(),ChangeDetectorRef.markForCheck()
In Zoneless entfällt die automatische Bindung an Browser-APIs. Stattdessen triggern:
- Signal-Updates, die im Template gelesen werden
async-Pipe-Emissions- Template-Event-Bindings (
(click)etc. — die werden auch in Zoneless registriert) - Manuelle
markForCheck/ApplicationRef.tick()
Top-Down-Walk durch jede Component
Der klassische Modus ist ChangeDetectionStrategy.Default — in Angular v21 wurde er als Eager aliased, weil der Name besser beschreibt, was passiert: Bei jedem Trigger läuft Change Detection durch jede Component im Baum. Keine Optimierung, keine Skip-Logik. Das ist einfach, robust und für kleine Apps völlig in Ordnung — aber bei großen Trees frisst es CPU.
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-default-counter',
standalone: true,
// Default ist auch ohne explizite Angabe gesetzt.
// In v21 ist ChangeDetectionStrategy.Eager der neue Alias-Name.
changeDetection: ChangeDetectionStrategy.Default,
template: `
<p>Count: {{ count }}</p>
<button (click)="count = count + 1">+1</button>
<p>Render-Tick: {{ tick() }}</p>
`,
})
export class DefaultCounterComponent {
count = 0;
private renders = 0;
// Wird bei JEDEM CD-Cycle aufgerufen — auch wenn nichts in dieser Component
// sich geändert hat. Bei Default gibt es keine Skip-Optimierung.
tick() {
return ++this.renders;
}
}Klick auf den Button → count ändert sich → Cycle läuft → tick() wird neu evaluiert → Render-Counter steigt. Aber: Auch ein Klick auf einen Button in einer ganz anderen Component triggert tick() hier. Methoden im Template sind genau deshalb in Default-Apps so teuer.
CD läuft nur unter klar definierten Bedingungen
Mit OnPush schaltet Angular die automatische Prüfung dieser Component und ihres Subtrees ab — bis genau eine der folgenden Bedingungen eintritt:
- Eine der Component-
@Input-Properties bekommt eine neue Reference zugewiesen (Reference-Equality, nicht Deep-Compare). - Ein Event aus dem Template dieser Component feuert (
(click),(input)etc.). - Die
async-Pipe im Template emittiert einen neuen Wert (sie ruft internmarkForCheck()). - Ein Signal, das im Template gelesen wird, ändert seinen Wert.
- Manueller Aufruf von
markForCheck()oderdetectChanges()auf derChangeDetectorRef.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-onpush-counter',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Count: {{ count() }}</p>
<button (click)="inc()">+1</button>
<p>Render-Tick: {{ tick() }}</p>
`,
})
export class OnPushCounterComponent {
count = signal(0);
private renders = 0;
inc() {
this.count.update((n) => n + 1);
}
tick() {
return ++this.renders;
}
}Hier ist tick() immer noch eine Methode im Template — aber sie läuft nur, wenn diese Component re-rendert. Klick auf einen Button in einer anderen OnPush-Component lässt sie in Ruhe.
| Strategie | Trigger jeder beliebige Event | Trigger nur lokal | Methoden im Template billig | Default ab v22 |
|---|---|---|---|---|
Default/Eager | Ja | — | Nein | Nein |
OnPush | Nein | Ja | Ja, im lokalen Re-Render | Ja |
Markieren statt Erzwingen
Beide Methoden leben auf ChangeDetectorRef und werden gerne verwechselt. Sie machen aber fundamental unterschiedliche Dinge.
markForCheck()markiert die Component und alle ihre Vorfahren bis zur Root als dirty. Im nächsten Cycle prüft Angular sie regulär. Sehr günstig, sehr typisch in OnPush-Components.detectChanges()läuft synchron sofort — und nur durch diese Component und ihre Children. Nicht für die Vorfahren. Teurer, aber präzise: Wenn du nach einer imperativen Operation jetzt den DOM aktualisiert haben musst.
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
} from '@angular/core';
@Component({
selector: 'app-mark-vs-detect',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Status: {{ status }}</p>
<button (click)="markPath()">markForCheck</button>
<button (click)="forceNow()">detectChanges</button>
`,
})
export class MarkVsDetectComponent {
private cdr = inject(ChangeDetectorRef);
status = 'idle';
markPath() {
// Aus einer externen Quelle (z. B. WebSocket, RxJS-Subscription außerhalb async-pipe)
// wird der Status gesetzt. Da OnPush ist, müssen wir markieren.
this.status = 'updated via markForCheck';
this.cdr.markForCheck();
// Re-Render passiert im nächsten CD-Cycle, nicht synchron hier.
}
forceNow() {
this.status = 'updated via detectChanges';
// Synchron jetzt — der DOM ist nach diesem Aufruf aktualisiert.
this.cdr.detectChanges();
}
}Browser-API-Patching als historische Krücke
Zone.js stammt aus einem TC39-Proposal von Microsoft, das nie standardisiert wurde. Angular hat es seit v2 als Default-Mechanik adoptiert: Zone.js patcht beim Bootstrap jede async Browser-API, die einen Callback nimmt — setTimeout, addEventListener, Promise, XMLHttpRequest, fetch, MutationObserver, requestAnimationFrame und Dutzende weitere. Nach jedem Callback ruft Zone.js Angular auf: „Tick durch.”
Der Charme: Du musst nichts tun. Setze this.x = 5 in einem setTimeout, und der DOM aktualisiert sich. Der Preis:
- Bundle-Größe: rund 30 kB gzipped, die Zone.js zur App hinzufügt.
- Performance: Jeder DOM-Event triggert einen kompletten CD-Cycle, auch wenn sich nichts geändert hat.
- Debugging: Stack Traces gehen durch Zone-Wrapper, die jeden Frame umschließen.
- Konflikte: Drittpartei-Libraries, die ihre eigenen Patches anlegen, brechen unvorhersehbar.
// Konzept-Skizze: Was Zone.js zur Laufzeit ungefähr tut.
// (Nicht der echte Code — nur das Mental Model.)
const originalSetTimeout = window.setTimeout;
window.setTimeout = function patched(fn, delay) {
return originalSetTimeout(() => {
try {
fn();
} finally {
// Nach JEDEM Timeout-Callback bekommt Angular Bescheid.
NgZone.current.runOutsideAngular(() => {});
ApplicationRef.tick();
}
}, delay);
};
// Identisch für: addEventListener, Promise.then, fetch, XMLHttpRequest,
// requestAnimationFrame, MutationObserver, EventSource, WebSocket, ...
// Die Liste ist lang, deshalb der Bundle-Aufschlag.App ohne Zone.js bootstrappen
Seit Angular v18 gibt es einen experimentellen Zoneless-Modus, der seit v20 via provideZonelessChangeDetection stabilisiert wurde und in v21 der Default für neue Apps ist. Der Ansatz: Angular verlässt sich auf explizite Notifizierungen durch Signals, async-Pipe und Template-Events — Zone.js fliegt komplett raus.
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRouter(routes),
],
};Plus: zone.js aus den Polyfills von angular.json entfernen ("polyfills": [] oder dort einfach den Eintrag löschen) und npm uninstall zone.js. Das spart die ~30 kB sofort.
Das perfekte Paar für Zoneless
Signals tragen ihre eigene Dependency-Tracking-Logik. Wird ein Signal im Template gelesen, registriert sich die View als Konsument. Ändert das Signal seinen Wert, weiß Angular exakt, welche Views als dirty markiert werden müssen — kein Top-Down-Walk durch den ganzen Tree, keine Heuristik, keine Zone.js-Vermittlung.
import { ChangeDetectionStrategy, Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-signals-onpush',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h3>Score: {{ score() }}</h3>
<h3>Status: {{ status() }}</h3>
<button (click)="inc()">+1</button>
`,
})
export class SignalsOnPushComponent {
score = signal(0);
status = computed(() => (this.score() >= 10 ? 'gewonnen' : 'spielt'));
inc() {
this.score.update((n) => n + 1);
}
// Kein markForCheck nötig, kein Zone.js nötig — der Signal-Read im Template
// ist die Trigger-Quelle, OnPush ist die richtige Strategie dazu.
}In dieser Component braucht es weder ChangeDetectorRef noch Zone.js noch eine async-Pipe — und sie ist trotzdem maximal reaktiv. Das ist die Richtung, in die Angular die nächsten Versionen führt.
Was sich ändert — und wie du dich vorbereitest
Angular v22 macht OnPush zum Default für alle Components ohne explizite changeDetection-Angabe. Was kurzfristig nach einem Breaking Change aussieht, ist in der Praxis selten ein Problem — denn Apps, die heute auf Default setzen, profitieren oft sogar: Sie hatten schlicht zu viele unnötige Re-Renders.
Die echten Risiko-Stellen beim Umstieg:
@Input-Mutationen statt -Replacements — wenn Parent-Code ein Array perpush()verändert statt eine neue Reference zu setzen.- Methoden im Template, die State zurückgeben — wenn die Methode den State mutiert, statt ihn nur zu lesen, läuft das Pattern unter OnPush nicht mehr.
- Subscriptions ohne
async-Pipe — wer eigenesubscribe(...)-Calls inngOnInitmacht und Properties setzt, braucht künftigmarkForCheck().
| Today | v22-ready |
|---|---|
this.items.push(newItem) | this.items = [...this.items, newItem] |
data$.subscribe(d => this.data = d) | data = toSignal(data$) oder async-Pipe |
| Methode im Template mit Side-Effect | computed() oder Property-Cache |
setTimeout(() => this.x = 1) | Signal-Update oder explizit markForCheck() |
Mutation, Setter im Template, Lifecycle-Schreiben
Drei Klassiker, die die Hälfte aller OnPush-Bugs ausmachen — mit konkretem Code:
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'app-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (item of items; track item) {
<li>{{ item }}</li>
}
`,
})
export class ListComponent {
@Input() items: string[] = [];
}
// Parent:
// SCHLECHT — Mutation, Reference bleibt gleich, OnPush feuert NICHT.
// this.items.push('neu');
// GUT — neue Reference, OnPush sieht den Change.
// this.items = [...this.items, 'neu'];import { AfterViewInit, Component, signal } from '@angular/core';
@Component({
selector: 'app-changed-error',
standalone: true,
template: `<p>Status: {{ status() }}</p>`,
})
export class ChangedErrorComponent implements AfterViewInit {
status = signal('initial');
ngAfterViewInit() {
// Wirft im Dev-Mode: ExpressionChangedAfterItHasBeenCheckedError
// Der CD-Cycle ist vorbei, das Template wurde gerendert,
// jetzt ändern wir den State noch im selben Tick.
this.status.set('after view init');
// FIX: Update auf den nächsten Microtask schieben.
queueMicrotask(() => this.status.set('after view init'));
}
}DevTools, Profiler, gezielte Detach-Patterns
Wenn eine Component zu viel rendert, gibt es konkrete Werkzeuge zur Diagnose und Lösung:
- Angular DevTools (Chrome/Firefox-Extension) → Profiler-Tab: Zeichnet auf, welche Components in welchem Cycle laufen und wie lange. Ohne dieses Tool ist Performance-Tuning Rätselraten.
ng.profiler.timeChangeDetection()in der Browser-Console: Misst CD-Kosten der gesamten App über mehrere Cycles.@formittrackstatt*ngFor: Stabiles Tracking spart bei langen Listen massiv DOM-Updates.computed(): Memoization out of the box, perfekt gegen teure Methoden im Template.ChangeDetectorRef.detach()+ manuellesdetectChanges(): Für hochfrequente Updates (Charts, Animationen) trennt man die Component vom CD-Tree und re-attacht sie nur, wenn nötig.
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
inject,
} from '@angular/core';
@Component({
selector: 'app-live-chart',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<canvas #c></canvas><p>FPS: {{ fps }}</p>`,
})
export class LiveChartComponent implements OnInit, OnDestroy {
private cdr = inject(ChangeDetectorRef);
private rafId = 0;
fps = 0;
ngOnInit() {
// Component vom CD-Tree trennen — Angular ignoriert sie.
this.cdr.detach();
const loop = () => {
this.fps = Math.random() * 60;
// Manuell rendern, wann WIR es wollen, nicht wenn Angular meint.
this.cdr.detectChanges();
this.rafId = requestAnimationFrame(loop);
};
this.rafId = requestAnimationFrame(loop);
}
ngOnDestroy() {
cancelAnimationFrame(this.rafId);
// reattach() ist optional, da die Component sowieso zerstört wird.
}
}Vorher / Nachher
Eine User-Liste mit Filter — ein klassisches Beispiel, das in Default-Apps oft mit BehaviorSubject und Methoden-im-Template aussieht. Hier die Refactor-Demo:
import { Component } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
// SCHLECHT: Default-CD, Methode im Template, BehaviorSubject ohne async-pipe.
@Component({
selector: 'app-user-list-legacy',
standalone: true,
template: `
<input (input)="filter$.next($any($event.target).value)" />
<ul>
@for (u of filtered(); track u.id) {
<li>{{ u.name }}</li>
}
</ul>
`,
})
export class UserListLegacyComponent {
users = [{ id: 1, name: 'Anna' }, { id: 2, name: 'Bert' }];
filter$ = new BehaviorSubject('');
// Läuft bei JEDEM CD-Cycle — auch wenn nichts den Filter geändert hat.
filtered() {
const q = this.filter$.value.toLowerCase();
return this.users.filter((u) => u.name.toLowerCase().includes(q));
}
}import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
// GUT: OnPush, Signals, computed memoisiert die Filter-Berechnung.
@Component({
selector: 'app-user-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input [value]="filter()" (input)="filter.set($any($event.target).value)" />
<ul>
@for (u of filtered(); track u.id) {
<li>{{ u.name }}</li>
}
</ul>
`,
})
export class UserListComponent {
users = signal([{ id: 1, name: 'Anna' }, { id: 2, name: 'Bert' }]);
filter = signal('');
// Wird NUR neu berechnet, wenn users() oder filter() sich ändern.
filtered = computed(() => {
const q = this.filter().toLowerCase();
return this.users().filter((u) => u.name.toLowerCase().includes(q));
});
}Die zweite Variante hat weniger Code, ist schneller (Memoization über computed), funktioniert in Zoneless ohne Anpassung und überlebt die v22-Migration ohne Änderung.
Häufige Fehler
@Input-Mutation triggert OnPush nicht
OnPush vergleicht @Input-Properties per Reference-Equality (===). Wer ein Array per push() oder ein Object per Property-Zuweisung mutiert, ändert die Reference nicht — Angular sieht keinen Change und rendert nicht neu. Lösung: immer eine neue Reference erzeugen ([…arr, x], { …obj, key: v }) oder direkt auf Signals setzen.
ExpressionChangedAfterItHasBeenCheckedError nur im Dev-Mode
Der Fehler entsteht, wenn du State in einem Lifecycle-Hook (typisch ngAfterViewInit) änderst, der nach dem Render-Pass läuft. Im Prod-Build verschwindet die Meldung, das Problem bleibt. Lösung: Update über queueMicrotask auf den nächsten Tick schieben oder den State im richtigen Hook (ngOnInit, Signal-Initializer) setzen.
markForCheck markiert den Pfad zur Root
markForCheck() markiert die Component UND alle Vorfahren bis zur App-Root als dirty. Im nächsten Cycle werden sie regulär geprüft — nicht synchron sofort. Wer einen synchronen Re-Render erwartet, ruft das falsche Tool auf; detectChanges() wäre das passende Werkzeug.
async-Pipe ruft intern markForCheck
Deshalb funktioniert sie in OnPush-Components ohne extra Code. Wer stattdessen manuell subscribe(…) ruft und Properties setzt, muss in OnPush selbst markForCheck() aufrufen — oder besser auf toSignal() umstellen.
Zoneless ohne Signals ist tricky
Wenn du Zone.js entfernst, aber weiter mit Promises, setTimeout und manuellen Subscriptions arbeitest, löst nichts mehr automatisch ein Re-Render aus. Du brauchst entweder Signal-Updates, async-Pipe oder explizites markForCheck(). Der saubere Weg: State auf Signals umstellen, dann ist Zone.js gar kein Thema mehr.
ChangeDetectorRef.detach trennt Component temporär vom Tree
Ein Performance-Trick für hochfrequente Updates (Charts, Game-Loops): detach() nimmt die Component aus dem CD-Tree, manuelles detectChanges() rendert sie kontrolliert. Aber: reattach() ist Pflicht, sobald reguläre CD wieder gewünscht ist — sonst friert der State sichtbar ein.
v22 macht OnPush zum Default
Apps, die heute auf Default bauen und Mutation statt Reference-Replace verwenden, müssen vorbereiten: Arrays/Objects spreaden, Subscriptions auf toSignal() umstellen, Methoden im Template durch computed() ersetzen. Wer schon heute changeDetection: OnPush setzt, ist v22-ready ohne weitere Arbeit.
ngDoCheck läuft IMMER bei jedem Cycle
Auch in OnPush-Components. Teure Logik in ngDoCheck ist deshalb ein fataler Anti-Pattern — der Hook wurde für Custom-Change-Detection-Logik gebaut, nicht für Geschäftslogik. Wer in ngDoCheck Filter berechnet oder API-Daten verarbeitet, hat OnPushs Vorteil komplett aufgegeben.
Weiterführende Ressourcen
Externe Quellen
- Runtime Performance – Angular.dev
- ChangeDetectionStrategy – Angular.dev
- ChangeDetectorRef – Angular.dev
- Zoneless Guide – Angular.dev
- provideZonelessChangeDetection – Angular.dev