Change Detection
Change detection (CD) is how Angular keeps the DOM in sync with your application's data. Angular checks whether bound values have changed and updates the view accordingly. Understanding CD is essential for building high-performance Angular applications — and for debugging mysterious "why isn't my template updating?" bugs.
How Change Detection Works
Angular runs change detection in response to async events:
- User interactions (click, keypress, input)
- HTTP responses
- Timer callbacks (setTimeout, setInterval)
- WebSocket messages
- Promises resolving
These are tracked by Zone.js, which monkey-patches browser APIs to notify Angular when any async operation completes.
When Angular runs CD on a component, it:
- Evaluates all template expressions (bindings, pipes)
- Compares new values with the previous values
- If different, updates the DOM
- Runs CD on all child components
Default Change Detection Strategy
By default, Angular uses ChangeDetectionStrategy.Default. On every CD cycle, Angular checks every component in the tree — from root to leaf.
// Default strategy — checks on every CD cycle
@Component({
selector: 'app-list-item',
template: `<li>{{ item.name }}</li>`,
// changeDetection: ChangeDetectionStrategy.Default ← implicit default
})
export class ListItemComponent {
@Input() item!: { name: string; active: boolean };
}If you have 1000 components, Angular runs template evaluation for all 1000 on every button click. For most apps this is fine, but for data-heavy UIs it can cause jank.
OnPush Change Detection Strategy
ChangeDetectionStrategy.OnPush tells Angular: only check this component when one of these conditions is met:
- An
@Input()reference changes (new object/array reference) - An event is triggered from within this component (click, keyup, etc.)
- An
asyncpipe emits a new value - Change detection is triggered manually via
ChangeDetectorRef - A signal read in the template changes (Angular 17+)
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
// Only re-render when inputs change or template signals emit
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
`,
})
export class UserCardComponent {
@Input() user!: User;
}OnPush + Immutable Data
// Parent component
@Component({ selector: 'app-parent' })
export class ParentComponent {
user = { name: 'Alice', email: 'alice@example.com' };
updateName(newName: string): void {
// ✗ WRONG — mutates the object; OnPush child won't re-render
this.user.name = newName;
// ✓ CORRECT — creates a new object reference
this.user = { ...this.user, name: newName };
}
}OnPush with AsyncPipe
The async pipe integrates deeply with OnPush. It calls markForCheck() internally when the observable emits, so the component re-renders even under OnPush:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [AsyncPipe, CurrencyPipe],
template: `
@if (portfolio$ | async; as portfolio) {
@for (stock of portfolio.stocks; track stock.symbol) {
<div>{{ stock.symbol }}: {{ stock.price | currency }}</div>
}
}
`,
})
export class PortfolioComponent {
portfolio$ = this.marketService.getLivePortfolio();
constructor(private marketService: MarketService) {}
}OnPush + AsyncPipe is the performance sweet spot for data-driven Angular components. The component is dormant unless the observable emits.Manual Change Detection with ChangeDetectorRef
Sometimes you need to trigger or suppress change detection manually. Inject ChangeDetectorRef:
import { Component, ChangeDetectorRef, ChangeDetectionStrategy, inject } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-data-grid',
})
export class DataGridComponent {
private cdr = inject(ChangeDetectorRef);
data: Record<string, unknown>[] = [];
// Called from a third-party library callback (outside Angular zones)
onDataUpdate(newData: Record<string, unknown>[]): void {
this.data = newData;
// Manually notify Angular that this component needs to be checked
this.cdr.markForCheck();
}
// Immediately run CD for this component and its ancestors
forceRefresh(): void {
this.cdr.detectChanges();
}
// Detach the component — completely opt out of automatic CD
pauseUpdates(): void {
this.cdr.detach();
}
// Re-attach and resume
resumeUpdates(): void {
this.cdr.reattach();
this.cdr.markForCheck();
}
}Method | Effect |
|---|---|
markForCheck() | Marks this component and ancestors as dirty — CD will check them next cycle |
detectChanges() | Runs CD synchronously for this component and its children now |
detach() | Removes component from automatic CD — it will never check unless you call detectChanges() |
reattach() | Re-attaches the component to the CD tree |
checkNoChanges() | Asserts no changes — throws if bindings changed (development only) |
Signals and Change Detection (Angular 17+)
Signals bypass the traditional Zone.js-based CD mechanism entirely. When a signal used in a template changes, Angular schedules a re-render for that specific component — no matter which CD strategy is used:
import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-metrics',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Requests: {{ requestCount() }}</p>
<p>Success rate: {{ successRate() }}%</p>
`,
})
export class MetricsComponent {
requestCount = signal(0);
successCount = signal(0);
successRate = computed(() => {
const total = this.requestCount();
const success = this.successCount();
return total > 0 ? Math.round((success / total) * 100) : 0;
});
recordRequest(success: boolean) {
this.requestCount.update(n => n + 1);
if (success) this.successCount.update(n => n + 1);
// Angular automatically schedules a re-render for this component
// No Zone.js, no markForCheck() needed
}
}NgZone and Running Outside Angular
For performance-critical code that should not trigger CD (animations, polling, background processing), run it outside Angular's zone:
import { NgZone, inject } from '@angular/core';
@Component({ selector: 'app-animation' })
export class AnimationComponent {
private ngZone = inject(NgZone);
private cdr = inject(ChangeDetectorRef);
startAnimation(): void {
// Run the requestAnimationFrame loop outside Angular
// No CD triggered on every frame
this.ngZone.runOutsideAngular(() => {
const loop = () => {
this.updateCanvas(); // pure DOM/canvas manipulation
if (this.isRunning) {
requestAnimationFrame(loop);
} else {
// Re-enter Angular zone to update component state
this.ngZone.run(() => {
this.finalScore = this.calculateScore();
});
}
};
requestAnimationFrame(loop);
});
}
isRunning = true;
finalScore = 0;
private updateCanvas() { /* canvas drawing */ }
private calculateScore() { return 100; }
}Zoneless Angular (Experimental in Angular 18+)
Angular 18 introduced experimental Zoneless mode. With Zoneless, Zone.js is completely removed and Angular relies entirely on signals (and explicit markForCheck() calls) for change detection:
// main.ts — opt into Zoneless
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [
provideExperimentalZonelessChangeDetection(),
// No more ZoneJS — smaller bundle, faster startup
],
});// angular.json — remove zone.js from polyfills
{
"polyfills": []
}Performance Checklist
Use ChangeDetectionStrategy.OnPush on all presentational/leaf components
Pass immutable data to OnPush components — always create new object references
Use the async pipe to automatically handle Observable subscriptions and CD integration
Use signals for local state — they trigger targeted re-renders without Zone.js overhead
Run performance-critical loops (canvas, WebGL, polling) outside Angular with ngZone.runOutsideAngular()
Use trackBy in @for loops to prevent unnecessary DOM destruction/recreation
Avoid calling methods in templates — they run on every CD cycle; use computed signals or getters instead
Profile CD with Angular DevTools to identify slow components
Common CD Bugs and Fixes
Symptom | Likely Cause | Fix |
|---|---|---|
Template not updating after data change | Object mutated instead of replaced | Create new reference: { ...old, key: value } |
OnPush component stuck | Input mutated in parent | Use immutable updates in parent |
Data updates from outside Angular | Third-party callback outside Zone.js | Call markForCheck() or use ngZone.run() |
ExpressionChangedAfterItHasBeenChecked | Value changed during CD cycle | Use setTimeout(0) or restructure initialization |
CD running too often | Using functions in templates | Replace with computed signals or memoized properties |
Summary
Change detection is one of Angular's most important performance levers:
- Default CD: checks all components on every async event — simple but can be slow for large trees
- OnPush CD: only checks when inputs change, events fire, or async emits — much more efficient
- Signals: provide fine-grained, targeted updates without Zone.js overhead — the future of Angular CD
ChangeDetectorRefmethods give manual control when needed- Zoneless mode (Angular 18+) removes Zone.js entirely, relying on signals
Master these patterns and your Angular apps will be fast and responsive at any scale.