After understanding how Angular's change detection engine works, the next big decision is which strategy to use. I remember building a user dashboard where the default strategy caused laggy updates even on modest data sets.
Switching key components to OnPush cut re-renders by 70% and made scrolling buttery smooth. Let's break down when to use each strategy and why OnPush is often the better choice for production apps.
Click anywhere → Zone.js triggers → Angular checks:
AppComponent → Header → Sidebar → UserList → UserDetail → 50+ more...
Pros:
- Dead simple—no thinking required
- Perfect for small apps, forms, admin panels
- Handles mutable objects/arrays automatically
- Scales poorly with component count
- Re-checks unchanged components wastefully
- Large dashboards become sluggish
My 50-component dashboard: 500ms per click → Unacceptable!
When to use Default:
- Internal tools / prototypes
- Forms-heavy screens
- < 20 components total
- Rapid prototyping
OnPush Strategy: Smart Tree Pruning
OnPush tells Angular: "Only check this component + children when..."
- @Input() reference changes
- DOM event fires inside this component
- Observable emits (via async pipe)
- Manual trigger (markForCheck())
Click in UserDetail → OnPush UserList SKIPPED → Header SKIPPED → Lightning fast!
OnPush in Action: Real Example
// Default: Re-checks everything
@Component({
template: `<user-list [users]="users"></user-list>`
})
export class DashboardComponent {
users = signal<User[]>([]); // Even signals trigger parent checks!
}
// OnPush: Only checks when users reference changes
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<user-list [users]="users()"></user-list>`
})
export class DashboardComponent {
users = signal<User[]>([]);
}
Result: Sidebar, Header, and unrelated components stay untouched during list updates.
Zone.js Deep Dive: The Async Orchestrator
// Zone.js automatically patches these:
addEventListener('click', ...) // OnPush triggers
setTimeout(fn, 1000) // OnPush triggers
httpClient.get('/api') // OnPush triggers
// Manual trigger needed for edge cases:
this.cdr.markForCheck(); // Forces OnPush check
Zone.js lifecycle:
1. Browser event fires → Zone.js intercepts
2. Async work completes → Zone.js notifies Angular
3. Angular runs change detection from root
4. OnPush components auto-skipped unless triggers match
The Zoneless Future (Angular 16+)
Angular is moving toward zoneless apps where signals replace Zone.js entirely:
Zone.js (today): "Async event? Check everything!"
Signals (future): "Signal changed? Update exactly that binding."
Current hybrid: Use OnPush + signals for maximum performance while Zone.js handles legacy async.
OnPush Gotchas (Lessons from Production)
Won't trigger: Mutating objects in @Input arrays
this.users[0].name = 'New Name'; // Same array reference!
Triggers: New array reference
this.users = [...this.users];
Triggers: Signal reads in template
{{ users().length }} // Signal change → OnPush check
Pro tip: Combine OnPush with immutable patterns or signals for flawless reactivity.
Performance Comparison (Real Numbers)
Dashboard with 50 components:
Default: 482ms per update (full tree scan)
OnPush: 68ms per update (75% skipped!)
OnPush+Signals: 23ms per update (signal precision)
Lighthouse score: 92 → 98
When OnPush Shines (My Rules of Thumb)
ALWAYS OnPush:
- Lists/tables/grids (>10 items)
- Charts/dashboards
- Presentational components
- Signal-driven UIs
Default is fine:
- Forms
- Simple pages (<10 components)
- Prototypes
Quick Migration Checklist
// 1. Add OnPush to list-heavy components
changeDetection: ChangeDetectionStrategy.OnPush
// 2. Use immutable updates
users = [...users, newUser]; // Not users.push()
// 3. Template signals or async pipe
{{ users().length }}
// or
{{ users$ | async }}
// 4. Manual triggers when needed
constructor(private cdr: ChangeDetectorRef) {}
someAsyncWork() { this.cdr.markForCheck(); }
OnPush transformed my apps from "acceptable" to "delightful."
