Change detection is the mechanism Angular uses to keep the DOM in sync with component state. Every time something asynchronous happens — an HTTP response, a user click, a timer firing — Angular decides which parts of the UI might have changed and updates them. Understanding how change detection works, why it can become a performance bottleneck in large applications, and how to control it with OnPush, signals, and Angular’s emerging zoneless mode separates engineers who can reason about Angular performance from those who guess and pray.
Change Detection Strategies
| Strategy | Checks When | Use For |
|---|---|---|
Default |
Any async event anywhere in the app — zone.js triggers a full tree check | Prototyping, components with mutable inputs |
OnPush |
Only when an @Input() reference changes, an async pipe emits, or markForCheck() is called |
Presentational components, performance-critical subtrees |
| Signals (implicit) | Only when a signal read in the template changes — fine-grained, no zone needed | Modern Angular — components using signals for all state |
| Zoneless (experimental) | Driven entirely by signals and explicit marks — no zone.js overhead | Maximum performance, server-side rendering |
OnPush Triggers
| What Triggers OnPush Check | What Does NOT Trigger |
|---|---|
New @Input() object/array reference (=== changes) |
Mutating an input object in place (obj.name = 'new') |
Observable emitting via async pipe |
Observable emitting without async pipe |
| Signal value changing (read in template) | Promise resolving without zone notification |
cdr.markForCheck() called explicitly |
DOM event on a child with Default strategy |
| DOM event fired on this component or its children | Internal mutable state changes without signals |
OnPush — the signals graph tracks exactly which template bindings depend on which signals, and only those bindings are updated. The combination of signals for state + OnPush on presentational components is the recommended architecture for new Angular applications, giving both the expressiveness of reactive state and maximum rendering performance.ChangeDetectionStrategy.OnPush to all presentational (leaf) components from day one. The overhead is minimal and the performance gains compound — a task list with 100 TaskCardComponent items using OnPush and immutable inputs only re-checks the cards whose inputs changed, instead of re-checking all 100 on every mouse move. Use trackBy with ngFor alongside OnPush for the full list optimisation.@Input() to an OnPush component. If the parent does task.title = 'New Title' instead of task = { ...task, title: 'New Title' }, the reference is the same — Angular does not re-check the OnPush component and the UI does not update. Always produce new object references: spread operators, Array.map(), Array.filter(), signal.update().Complete Change Detection Examples
import {
Component, Input, ChangeDetectionStrategy, ChangeDetectorRef,
signal, computed, inject, OnInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// ── OnPush presentational component ──────────────────────────────────────
@Component({
selector: 'app-task-card',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article class="task-card" [class.task-card--overdue]="isOverdue">
<h3>{{ task.title }}</h3>
<span class="badge">{{ task.priority }}</span>
</article>
`,
})
export class TaskCardComponent {
@Input({ required: true }) task!: Task;
get isOverdue(): boolean {
return !!this.task.dueDate
&& new Date(this.task.dueDate) < new Date()
&& this.task.status !== 'completed';
}
// Only re-renders when task reference changes
}
// ── OnPush + signals — full modern pattern ────────────────────────────────
@Component({
selector: 'app-task-list',
standalone: true,
imports: [CommonModule, TaskCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul>
@for (task of filteredTasks(); track task._id) {
<app-task-card [task]="task"></app-task-card>
}
</ul>
`,
})
export class TaskListComponent implements OnInit {
private taskService = inject(TaskService);
tasks = signal<Task[]>([]);
filterStatus = signal('');
filteredTasks = computed(() => {
const s = this.filterStatus();
return s ? this.tasks().filter(t => t.status === s) : this.tasks();
});
ngOnInit(): void {
this.taskService.getAll()
.pipe(takeUntilDestroyed())
.subscribe(tasks => this.tasks.set(tasks));
// Signal update automatically schedules a view check — no markForCheck needed
}
}
// ── Manual markForCheck for non-zone async ────────────────────────────────
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ time }}</p>`,
})
export class ClockComponent implements OnInit {
time = '';
private cdr = inject(ChangeDetectorRef);
ngOnInit(): void {
setInterval(() => {
this.time = new Date().toLocaleTimeString();
this.cdr.markForCheck(); // notify Angular to check this component
}, 1000);
}
}
// ── Zoneless Angular (experimental, Angular 18+) ──────────────────────────
// app.config.ts
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
export const appConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideRouter(routes),
// All state changes must go through signals, async pipe, or explicit marks
],
};
// angular.json — also remove zone.js: "polyfills": []
How It Works
Step 1 — Zone.js Monkey-Patches Async APIs
Zone.js intercepts browser async APIs: setTimeout, Promise.then(), addEventListener(), XMLHttpRequest. When any of these complete, zone.js notifies Angular, which triggers a change detection cycle walking the entire component tree. With Default strategy, this happens for every async event — a mouse move can trigger hundreds of checks per second across thousands of components.
Step 2 — OnPush Creates a Subtree That Opts Out of Default Checking
An OnPush component and all its descendants are skipped during the default change detection walk unless one of the OnPush triggers fires. Angular marks the component “dirty” only when an input reference changes, an async pipe emits, a signal changes, or markForCheck() is called. This can reduce the number of components checked on each cycle from thousands to dozens.
Step 3 — Signals Provide Fine-Grained Reactivity
When a component template reads a signal with {{ count() }}, Angular registers that template as a consumer of the signal. When the signal changes, Angular marks only that component’s view as dirty — not the entire subtree. Combined with OnPush, this provides both coarse-grained (OnPush) and fine-grained (signals) control.
Step 4 — markForCheck() vs detectChanges()
cdr.markForCheck() marks the component and all its ancestors as dirty — the next cycle will check this component. cdr.detectChanges() immediately runs change detection for this component and its children synchronously. Use markForCheck() for most cases — it batches with other updates. Use detectChanges() only when you need immediate synchronous DOM update.
Step 5 — Zoneless Mode Requires Explicit Change Notification
Without zone.js, Angular has no automatic way to know something asynchronous happened. Every state change must go through signals, the async pipe, or explicit markForCheck() calls. With signals covering all component state, the vast majority of change detection works automatically — and the reward is a ~100KB smaller bundle and faster initial load.
Common Mistakes
Mistake 1 — Mutating inputs in OnPush components without new reference
❌ Wrong — object mutation does not trigger OnPush re-render:
updateTask(): void {
this.task.title = 'New Title'; // same reference — OnPush child won't update!
}
✅ Correct — always produce a new reference:
updateTask(): void {
this.task = { ...this.task, title: 'New Title' }; // new reference — re-renders
}
Mistake 2 — Using setInterval without markForCheck in OnPush component
❌ Wrong — interval fires but UI never updates:
ngOnInit(): void {
setInterval(() => { this.elapsed++; }, 1000); // zone not notified — no check
}
✅ Correct — use a signal:
elapsed = signal(0);
ngOnInit(): void {
setInterval(() => this.elapsed.update(n => n + 1), 1000);
}
Mistake 3 — Using OnPush with mutable service BehaviorSubject state
❌ Wrong — subscribe without async pipe or markForCheck:
ngOnInit(): void {
this.taskService.tasks$.subscribe(t => {
this.tasks = t; // OnPush doesn't know — UI stale!
});
}
✅ Correct — use async pipe or signal:
// In template: *ngFor="let task of tasks$ | async"
// OR: tasks = toSignal(this.taskService.tasks$, { initialValue: [] });
Quick Reference
| Task | Code / Pattern |
|---|---|
| Apply OnPush | changeDetection: ChangeDetectionStrategy.OnPush |
| Mark dirty manually | inject(ChangeDetectorRef).markForCheck() |
| Immediate sync update | inject(ChangeDetectorRef).detectChanges() |
| Signal triggers OnPush | Read signal in template — change auto-schedules check |
| Async pipe triggers OnPush | observable$ | async in template |
| New reference for OnPush | this.task = { ...this.task, field: value } |
| Zoneless mode | provideExperimentalZonelessChangeDetection() |