How does change detection work in Angular?
Change detection in Angular is the mechanism that walks the component tree and syncs DOM bindings when application state changes.
Theory
TL;DR
- Zone.js patches
setTimeout,fetch, andaddEventListenerto detect async activity and trigger a tree walk automatically - Default strategy checks every component on every event. OnPush skips a subtree unless its
@Input()reference changes or an Observable emits - OnPush +
asyncpipe cuts the number of checks by roughly 80% in apps with 50+ components - Angular compares by reference, not by value. Mutating an object won't trigger OnPush
Quick example
import { Component } from '@angular/core';
@Component({
selector: 'app-counter',
template: `<button (click)="count++">{{ count }}</button>`
})
export class CounterComponent {
count = 0;
}Click fires a DOM event. Zone.js intercepts it and schedules ApplicationRef.tick(), which walks the tree from root. Angular finds {{ count }} changed from 0 to 1 and patches the DOM. That is the whole cycle.
How Zone.js triggers the cycle
Angular does not watch data directly. Zone.js patches browser APIs: addEventListener, setTimeout, setInterval, XMLHttpRequest, fetch, and Promise. Any time one of these fires inside the Angular zone, it queues a microtask. When that microtask runs, NgZone.onMicrotaskEmpty fires and calls ApplicationRef.tick().
tick() starts at the root ViewRef (in Ivy, an LView) and walks down the entire tree. For each component view, Angular runs lifecycle hooks, diffs each binding against its last recorded value, and patches the DOM if anything changed. Ivy optimizes this with 8-byte flag fields on each LView to skip views already marked as clean.
Default vs OnPush
Default checks every component on every tick, no exceptions. A click anywhere in the app triggers checks on all 200 components, even if 195 have nothing to do with that click.
OnPush changes the rule. Angular skips a component and its subtree unless one of these is true: an @Input() reference changed, an event originated inside that component, an Observable pushed a value via async pipe, or you called markForCheck() manually.
The numbers tell the story: 100 components with Default means 100 checks per click. With OnPush and only 5 dirty inputs, it is 5 checks.
When to use each strategy
- Small app, under 10 components: Default works fine, zero config needed
- List or grid with 100+ rows: OnPush +
trackByin*ngFor - NgRx or Observable-driven app: OnPush +
asyncpipe everywhere - Frequent WebSocket updates or timers:
ChangeDetectorRef.markForCheck()inside the subscription
How Angular compares values
Angular uses reference equality (===), not deep comparison. This matters most for OnPush.
// OnPush sees the same object reference - no update
this.user.name = 'Bob';
// New reference - OnPush triggers a check
this.user = { ...this.user, name: 'Bob' };Immutable updates are the standard pattern in OnPush components for this reason. Same array reference? No check. New array from spread or .concat()? Check runs.
Common mistakes
Mutating @Input objects in a child component
// Parent passes user object, child mutates it directly
this.user.name = 'Bob'; // OnPush child never re-rendersOnPush compares object references. Mutation is invisible to it. Always return a new object or array from the parent.
Using setTimeout for state updates
setTimeout(() => this.data = newData, 0); // works but triggers full tree walkThis patches the whole tree every time. At scale it hurts. Use markForCheck() inside an OnPush component with Observable-driven state instead.
Detaching everywhere for "performance"
constructor(private cdr: ChangeDetectorRef) {
this.cdr.detach(); // this component no longer auto-checks
}Detached components need manual detectChanges() calls or the UI freezes on real data. Use detach only in OnPush leaf components with no user-facing events, and call detectChanges() sparingly.
Calling functions in templates
<div *ngFor="let item of getFilteredItems()">{{ item }}</div>getFilteredItems() runs on every change detection cycle. With a large list at 60fps, this tanks performance. Pre-compute the result in ngOnInit or use a pure: true pipe.
Real-world usage
- Angular Material table: OnPush +
trackByfor virtual scroll with 10k rows - NgRx:
asyncpipe in OnPush components as the default pattern in official docs - AngularFire: Zone.js patches Firebase realtime listeners automatically, no manual triggers needed
- Nx monorepos: Default for lazy-loaded feature modules, OnPush for shared lib components
Follow-up questions
Q: What exactly does Zone.js patch and why does Angular need it?
A: Zone.js patches addEventListener, setTimeout, setInterval, XMLHttpRequest, fetch, and Promise. Angular has no built-in way to know when something async happened inside component classes. Zone.js provides that signal, and Angular decides what to re-check.
Q: What happens in apps running without Zone.js?
A: Change detection must be triggered manually with ChangeDetectorRef.detectChanges() or markForCheck(). Angular Signals (Angular 16+) are the cleaner path: they notify Angular of specific changes without walking the whole tree.
Q: Default vs OnPush in numbers?
A: With 100 components and Default, every click runs 100 checks. With OnPush and 5 dirty inputs, it runs 5. Angular team benchmarks show around 80% fewer cycles in component-heavy dashboards.
Q: How does Ivy change detection differ from ViewEngine?
A: Ivy stores LView flags as tagged 8-byte pointers, letting it skip clean views in 2 instructions instead of traversing their whole structure. ViewEngine dirtied entire views. Angular 17+ Signals go further: fine-grained reactivity with no tree walk at all.
Q: Why use async pipe instead of manual subscribe()?
A: async pipe auto-unsubscribes on component destroy and calls markForCheck() automatically. With manual subscribe() in an OnPush component, you need to call markForCheck() yourself or the view never updates.
Examples
Basic counter with Default strategy
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = 0;
increment() {
this.count++; // Zone.js catches the click, tree walk starts
}
}Zone.js catches the click, tick() runs, Angular finds {{ count }} changed, DOM updates. With Default strategy, every component in the app gets checked on this single click.
Todo list with OnPush and async pipe
@Component({
selector: 'app-todos',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul>
<li *ngFor="let todo of todos$ | async; trackBy: trackById">
{{ todo.title }}
</li>
</ul>
`
})
export class TodosComponent {
todos$ = this.todoService.todos$; // Observable from HttpClient
trackById(index: number, todo: Todo) {
return todo.id; // stable identity for DOM node reuse
}
constructor(private todoService: TodoService) {}
}async pipe subscribes to the Observable and calls markForCheck() when a new value arrives. With trackBy, Angular reuses existing DOM nodes for unchanged items. 100 todos load once; only changed items re-render.
OnPush with immutable updates
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ user.name }}</p>`
})
export class UserComponent {
@Input() user: User = { name: 'Alice' };
}
// In parent - wrong: mutates existing object, reference stays the same
this.currentUser.name = 'Bob'; // UserComponent stays "Alice"
// In parent - correct: new object reference
this.currentUser = { ...this.currentUser, name: 'Bob' }; // UserComponent updates to "Bob"OnPush compares @Input() by reference (===). Mutating the object keeps the same reference, so Angular skips the subtree entirely. A new object forces the check. I have seen this exact bug in production OnPush dashboards where a name edit appeared to never take effect.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.