Change detection is how Angular keeps the DOM in sync with component state. Understanding it matters for performance and for knowing why an update sometimes doesn't appear when expected.
1. How Angular detects changes (Zone.js)
By default Angular uses Zone.js, which monkey-patches browser async APIs — setTimeout, Promise.then, addEventListener, XMLHttpRequest — so Angular knows when any async operation completes. After each one, Angular runs change detection: it walks the component tree from root to leaves, evaluates every template binding, and updates the DOM where a value has changed.
This means every component is checked after every async event, even if only one unrelated component triggered it.
2. Development mode double check
In development mode Angular runs change detection twice per cycle. The second pass verifies that the first pass did not produce side effects that changed binding values. If a binding returns a different value on the second pass, Angular throws:
ExpressionChangedAfterItHasBeenCheckedError
This always points to a real problem — state is being mutated during rendering (in a lifecycle hook, a getter with side effects, etc.). Fix the root cause; do not suppress it.
3. OnPush change detection strategy
By default every component uses ChangeDetectionStrategy.Default — it is checked on every change detection cycle. Setting a component to OnPush tells Angular to skip it unless one of these conditions is met:
- An
@Input()receives a new reference (not a mutated value) - An event fires from within the component or one of its children (click, keydown, etc.)
- An
asyncpipe in the template receives a new emission -
ChangeDetectorRef.markForCheck()is called manually - A signal read in the template changes (Angular 17+)
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-task-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`,
})
export class TaskListComponent {}
OnPush is most effective on leaf or mid-tree components with stable inputs — it prevents large subtrees from being checked when unrelated state changes elsewhere.
The Angular CLI defaults to
OnPushfor generated components as of Angular 19, reflecting the shift toward signal-based components where fine-grained updates make full-tree scanning unnecessary.
4. OnPush with service-shared state
The main pitfall of OnPush: if a service holds a plain object or array and mutates it in place without replacing the reference, OnPush components depending on it will not update — none of the five conditions above are triggered.
// ❌ mutating in place — OnPush component will not re-render
this.tasksService.tasks.push(newTask);
Solution A — signals in the service (recommended)
Signals work automatically with OnPush. When a signal changes, Angular is notified directly without Zone.js or a reference change.
// tasks.service.ts
@Injectable({ providedIn: 'root' })
export class TasksService {
tasks = signal<Task[]>([]);
addTask(task: Task) {
this.tasks.update(list => [...list, task]);
}
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (task of tasksService.tasks(); track task.id) {
<li>{{ task.title }}</li>
}
`,
})
export class TaskListComponent {
tasksService = inject(TasksService);
}
Solution B — BehaviorSubject + async pipe
The older pattern before signals. A BehaviorSubject holds the current value and emits it to new subscribers on demand. AsyncPipe subscribes, calls markForCheck() when a new value arrives, and unsubscribes on destroy automatically.
// tasks.service.ts
@Injectable({ providedIn: 'root' })
export class TasksService {
private _tasks = new BehaviorSubject<Task[]>([]);
tasks$ = this._tasks.asObservable();
addTask(task: Task) {
this._tasks.next([...this._tasks.value, task]);
}
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, NgFor],
template: `
<li *ngFor="let task of tasksService.tasks$ | async">{{ task.title }}</li>
`,
})
export class TaskListComponent {
tasksService = inject(TasksService);
}
Solution C — ChangeDetectorRef.markForCheck()
If you use a plain subscription instead of the async pipe, call markForCheck() manually after updating local state:
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class TaskListComponent implements OnInit {
private cdr = inject(ChangeDetectorRef);
tasks: Task[] = [];
ngOnInit() {
this.tasksService.tasks$.subscribe(tasks => {
this.tasks = tasks;
this.cdr.markForCheck(); // schedule this component for checking
});
}
}
5. ChangeDetectorRef
ChangeDetectorRef gives direct access to a component's slot in the change detection tree.
| Method | What it does |
|---|---|
markForCheck() |
Schedules this component and its ancestors for the next CD cycle (lazy) |
detectChanges() |
Synchronously checks this component and its children immediately |
detach() |
Removes this component from the CD tree entirely |
reattach() |
Re-attaches a previously detached component |
markForCheck() is lazy — it queues work for the next cycle. detectChanges() is immediate — use it when you need the DOM updated synchronously, such as after receiving a value from a non-Angular callback (e.g., a third-party library):
private cdr = inject(ChangeDetectorRef);
onExternalCallback(value: string) {
this.title = value;
this.cdr.detectChanges(); // DOM updates immediately, not on the next cycle
}
6. NgZone — running code outside Angular
Every async operation inside Angular's zone triggers change detection. High-frequency work — scroll listeners, canvas animations, WebSocket frames, setInterval-based polling — inside the zone causes Angular to re-check the entire tree on every tick.
NgZone.runOutsideAngular() runs code without Zone.js tracking it. When you need to push a result back to the UI, call NgZone.run() to re-enter the zone and trigger change detection.
import { NgZone, inject } from '@angular/core';
@Component({ ... })
export class ChartComponent implements OnInit {
private ngZone = inject(NgZone);
private rawValue = 0;
displayValue = 0;
ngOnInit() {
this.ngZone.runOutsideAngular(() => {
setInterval(() => {
this.rawValue = this.readSensor(); // runs every 16ms, no CD triggered
}, 16);
});
}
onUserAction() {
this.ngZone.run(() => {
this.displayValue = this.rawValue; // re-enters zone, CD runs after this
});
}
}
NgZone is only relevant in Zone.js-based apps. In zoneless mode there is no zone to escape from.
7. Zoneless mode (Angular 18+)
In zoneless mode Zone.js is not loaded at all. Angular no longer triggers change detection after async events. Instead, updates happen only when Angular is explicitly notified:
- A signal read in a template changes
-
ChangeDetectorRef.markForCheck()ordetectChanges()is called -
ApplicationRef.tick()is called manually
This makes OnPush the effective behaviour for all components — Angular only visits components it knows have changed state.
Enabling zoneless
// app.config.ts
import { provideZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(), // Angular 19+ name (was provideExperimentalZonelessChangeDetection in 18)
],
};
Remove zone.js from polyfills in angular.json:
"polyfills": []
Zoneless was developer preview in Angular 18, stable in Angular 19, and became the default in Angular 21.
Signals are the foundation
In a zoneless app, signals are the primary driver of change detection. Components that read signals get automatic, fine-grained DOM updates without Zone.js, OnPush boilerplate, or ChangeDetectorRef:
| Approach | Zone.js | OnPush |
markForCheck() |
|---|---|---|---|
| Default strategy + Zone.js | Required | Not needed | Not needed |
OnPush + Zone.js |
Required | Opt-in | Sometimes |
OnPush + signals + Zone.js |
Can remove | Auto-compatible | Not needed |
| Zoneless + signals | Not loaded | Irrelevant | Not needed |
Top comments (0)