Angular OnPush Change Detection: When It Helps and What Breaks Without It
Angular's OnPush strategy cuts change-detection cycles by skipping components whose inputs haven't changed by reference. Learn the immutable data rules, zone pitfalls, and verification steps that make it work.
08 Jan 2026, 19:20 UTC

The performance problem OnPush solves
Angular's default change detection strategy runs a check on every component after every asynchronous event—clicks, timers, HTTP responses, and promise resolutions. In a large component tree this means hundreds or thousands of checks per second, most of which find no changes. ChangeDetectionStrategy.OnPush tells Angular to skip a component and its entire subtree unless one of three things happens: an @Input() reference changes, an event originates inside the component, or you explicitly call ChangeDetectorRef.markForCheck() or detectChanges().
How the reference check works
With OnPush, Angular compares the previous and new values of each input using reference equality (===), not deep equality. If the reference is identical, the component is skipped. This is why OnPush forces an immutable data discipline: replacing an array or object creates a new reference, while mutating it in place leaves the reference unchanged and the UI stale.
A worked parent-child example
Consider a list parent that passes an array to a child. The child uses OnPush and logs when Angular checks it.
// child.component.ts
import { Component, Input, DoCheck, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-list-item',
template: `<div>{{ item.name }}</div>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListItemComponent implements DoCheck {
@Input() item: { name: string };
ngDoCheck() {
console.log('ListItemComponent checked');
}
}
// parent.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-list',
template: `
<button (click)="addBad()">Add (mutating)</button>
<button (click)="addGood()">Add (immutable)</button>
<app-list-item *ngFor="let i of items" [item]="i"></app-list-item>
`
})
export class ListComponent {
items = [{ name: 'Alpha' }];
addBad() {
// Mutates the existing array; reference stays the same
this.items.push({ name: 'Beta' });
}
addGood() {
// Creates a new array reference; OnPush sees the change
this.items = [...this.items, { name: 'Beta' }];
}
}
Clicking Add (mutating) leaves the reference unchanged, so Angular skips the child and the new item never appears. Clicking Add (immutable) replaces the array, the input reference changes, and the child updates.
When OnPush still runs change detection
OnPush does not freeze a component forever. Angular will check it when:
- A bound
@Input()receives a new object reference. - An event handler fires from the component's template (for example, a
(click)on its own DOM). - You call
markForCheck()on the component'sChangeDetectorRef, which marks the path back to the root as dirty. - You call
detectChanges(), which runs a local check immediately regardless of the strategy.
Limits and common mistakes
Mutating nested objects
Because Angular only checks the top-level input reference, mutating a property inside an input object is invisible. If item is an object and you run this.item.name = 'Gamma', the parent's items array reference is unchanged, so nothing updates. Replace the object instead: this.items = this.items.map(i => i.id === id ? { ...i, name: 'Gamma' } : i);
Async work outside the Angular zone
OnPush relies on Angular's zone to know when an event happened. Code that runs outside the zone—raw setTimeout, fetch callbacks, WebSocket messages, or some third-party libraries—will not trigger a check even if the component uses OnPush. Wrap the update in ngZone.run() or emit through an EventEmitter that Angular is already listening to.
Applying OnPush to the wrong components
Leaf components with no @Input() bindings gain nothing from OnPush because there is no reference to compare. The strategy pays off on components that receive data and sit in frequently re-rendered branches.
Forgetting markForCheck with observables
If a service pushes data through a plain Subject and a component subscribes manually, the emission may occur outside Angular's automatic detection. Calling markForCheck() inside the subscription ensures the view updates. The async pipe handles this automatically because it calls markForCheck() internally.
Verifying that OnPush is active
You can confirm the strategy is working without profiling tools. Add a ngDoCheck hook to the child component and log a message. With OnPush, the log appears only when an input reference changes or an event fires in that component—not on every global tick.
For a broader performance picture, open Chrome DevTools, record a Performance profile while interacting with the app, and look for reduced change-detection flame-chart activity in the OnPush subtree compared with a default-strategy version.
Quick comparison
| Scenario | Default strategy | OnPush |
|---|---|---|
| Parent mutates an array in place | Child updates | Child stays stale |
| Parent replaces the array reference | Child updates | Child updates |
| Unrelated component fires a timer | Child is checked | Child is skipped |
| Child's own button is clicked | Child is checked | Child is checked |
OnPush is not a universal switch. It is a deliberate contract: you promise Angular that inputs are immutable and that async boundaries are bridged back into the framework. When those rules hold, it removes redundant work from large trees without changing application behavior.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.