· frameworks · 7 min read
The Great Angular Performance Debate: Change Detection vs. Zone.js
A practical guide to the controversies and trade-offs between Angular's change detection strategies and Zone.js. Learn when to rely on Zone.js, when to prefer OnPush or manual detection, and how to measure / migrate safely.

What you’ll get from this article
You will walk away with a clear decision path: when to rely on Zone.js for convenience, when to optimize with OnPush and manual detection, and when a zone-less approach or Angular Signals makes sense. You’ll also get patterns, code examples, and measurement strategies to avoid premature optimization.
Short outcome first. Then the why and how.
The problem in one line
Angular’s change detection checks component trees after async work. That is powerful and simple. It can also be wasteful.
Zone.js helps by automatically triggering checks after nearly every asynchronous operation. That simplicity is golden for productivity. But it can lead to excessive checks and a performance debate: do we accept the convenience cost, or do we take control and optimize manually?
How Angular change detection works (brief)
Angular uses a tree-based change detection model. When change detection runs, Angular traverses the component tree (or parts of it) and evaluates templates to see if anything changed and needs DOM updates. There are several ways to control or influence when that traversal happens:
- Default strategy (CheckAlways) - Angular checks the component every change detection cycle.
- OnPush - Angular checks a component only when its input references change, an event originates in the component, or you explicitly ask for a check.
- Manual control using ChangeDetectorRef (detectChanges, markForCheck, detach, reattach).
For full details see the official guide: https://angular.io/guide/change-detection and the API docs: https://angular.io/api/core/ChangeDetectionStrategy
Zone.js: what it does and why it exists
Zone.js is a library that patches async APIs (timers, fetch, DOM events, promises, XHR, etc.) so Angular can detect when async work finishes and automatically schedule change detection. It makes Angular just work without you having to wire change detection into every async callback.
- Repository and docs: https://github.com/angular/zone.js
- NgZone API: https://angular.io/api/core/NgZone
Pros
- Developer convenience: automatic change detection after async operations.
- Less boilerplate: you rarely need to manually call detectChanges or markForCheck.
- Predictability for many common apps and third-party libraries that expect zone behavior.
Cons
- Over-triggering: many small async events can cause frequent checks.
- Third-party or low-level code that uses many timers/promises can cause unnecessary cycles.
- Harder to reason about performance in large UIs without tooling.
The main controversies summarized
Convenience vs Control
- Zone.js favors convenience and a simple mental model. It hides the plumbing of change detection.
- Manual control (OnPush, detach, NgZone.runOutsideAngular) trades convenience for performance and explicitness.
Predictability vs Performance
- Simply relying on Zone.js works and is predictable for many apps.
- High-performance apps (big lists, frequent timers, animation-heavy) often need explicit strategies to avoid thrashing.
Migration and ecosystem compatibility
- Some libraries assume zone behavior. Moving to zone-less or NgZone noop mode can break those unless you adapt them.
- New Angular primitives (Signals) encourage more fine-grained reactivity that reduces reliance on a global zone.
Tooling and debugging
- Zone-based detection can make it harder to pinpoint why change detection ran.
- Manual approach puts the responsibility on you-good tools and profiling are required.
Practical strategies and when to choose each approach
I’ll give concise, actionable rules. Then deeper patterns and examples.
Rule-of-thumb decision matrix
- Start with Zone.js enabled + Default strategy? Use this only for prototypes, very small apps, or if developer productivity beats raw performance need.
- Start with OnPush + Zone.js? Good default for most production apps. Combines performance with familiarity.
- Use NgZone.runOutsideAngular for expensive non-UI async work (or heavy loops). Only re-enter the zone to update UI.
- Consider zone-less (NgZone: ‘noop’ or Signals with minimal zone usage) if you must get the absolute smallest change detection surface and you have the expertise to manage the complexity and library compatibility.
Now the patterns.
1) The sensible default: OnPush + async pipe
Why: OnPush avoids change detection unless inputs change by reference or an event happens inside the component. Use immutable data and the async pipe to let Angular update templates without calling markForCheck yourself.
Example:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'user-list',
template: `
<li *ngFor="let u of users$ | async; trackBy: trackById">{{ u.name }}</li>
`,
})
export class UserListComponent {
users$: Observable<User[]> = this.store.users$; // immutable updates in store
trackById(_idx: number, item: User) {
return item.id;
}
}Benefits: fewer checks, simpler code, less manual detection.
Reference: https://angular.io/guide/change-detection#onpush-change-detection
2) When you need more control: ChangeDetectorRef patterns
- markForCheck(): mark a component to be checked on the next cycle (useful when inputs change indirectly).
- detectChanges(): run change detection for a subtree right now.
- detach()/reattach(): isolate expensive tree parts and call detectChanges manually.
Example: detaching a heavy chart component and manually updating it every second.
constructor(private cd: ChangeDetectorRef) {}
ngOnInit() {
this.cd.detach();
setInterval(() => {
// update data for chart
this.updateChartData();
this.cd.detectChanges();
}, 1000);
}3) Run heavy work outside Angular: NgZone.runOutsideAngular
If a non-UI background loop or animation is generating lots of microtasks, run it outside the zone to avoid unnecessary checks.
constructor(private ngZone: NgZone) {}
startHeavyLoop() {
this.ngZone.runOutsideAngular(() => {
requestAnimationFrame(() => {
// heavy DOM-free work or sampling
// only call ngZone.run(() => ...) when you need to update the UI
});
});
}Reference: https://angular.io/api/core/NgZone
4) Zone-less apps and Signals
Angular introduced Signals (reactive primitives) to build apps with fine-grained reactivity. Signal updates trigger the minimal set of updates and can reduce the need for a global zone-based detection strategy. A zone-less approach (bootstrapping with ngZone: ‘noop’) is possible, but you must wire change triggers explicitly.
- Learn Signals: https://angular.io/guide/signals
Zone-less is powerful for extreme performance, but it increases mental and integration costs. Some libraries assume Zone.js; migrating them needs attention.
Real costs and risks of ripping Zone.js out
- Third-party compatibility: Many libraries (and some Angular dev tools) assume zone behavior. You may need to wrap callbacks or re-add change triggers.
- Developer ergonomics: More explicit calls to change detection in many places. This increases boilerplate and risk of missing updates.
- Increased cognitive load: You must reason about where to trigger updates; forgetting markForCheck or detectChanges leads to stale UI.
- Harder onboarding: New developers often rely on the “it just works” model from zone-based detection.
That said, for very large, interactive UIs with many microtasks, the performance wins are real.
Measurement-first approach (do this before major change)
- Profile first. Use Chrome DevTools and Angular profiler (or Lighthouse) to find hotspots.
- Measure the current change detection cost. Look for frequent ticks, long frames, and expensive template bindings.
- Try OnPush with immutability and async pipe. Re-measure.
- If still a problem, apply targeted NgZone.runOutsideAngular or detach an expensive subtree. Re-measure.
- Only if necessary and after understanding compatibility issues: consider going zone-less or using Signals more aggressively.
Useful references:
- Angular performance guide: https://angular.io/guide/performance
- Debugging change detection issues: https://indepth.dev/posts/1427/angular-change-detection-a-powerful-and-subtle-beast (community resource)
Real-world patterns and anti-patterns
Do this
- Use OnPush for components that receive data via inputs or Observables.
- Use immutable state or event-driven state changes.
- Use async pipe to subscribe in templates and avoid manual subscriptions.
- Use trackBy for ngFor to avoid recreating DOM nodes.
- Profile using devtools before mass changes.
Avoid this
- Using many timers or microtasks in components without running them outside the Angular zone.
- Mutating large arrays/objects in place when using OnPush.
- Toggling change detection strategies without measuring.
Example migration checklist from Zone.js heavy app to a performant app
- Add OnPush to leaf components and replace local subscriptions with async pipe.
- Make state updates immutable (or use a reactive state library).
- Add trackBy for ngFor lists.
- Identify hot spots (animations, frequent timers, websockets). Run those outside the zone or batch updates.
- Use detach on components that update at known rates and call detectChanges from a single scheduler.
- If moving to ngZone: ‘noop’, create adapters for libraries that expect zone re-entry or explicit UI update hooks.
When you should strongly consider a zone-less architecture
- You are building a massive, highly interactive UI (e.g., trading dashboards, real-time collaboration) where every millisecond counts.
- You’ve exhausted simpler optimizations and profiling shows that zone-induced ticks are the bottleneck.
- You have the engineering capacity to fix library compatibility and to maintain explicit change triggers.
If that describes you, Signals combined with minimal zone usage can produce great results. Otherwise, OnPush + careful NgZone usage is the pragmatic sweet spot.
Final recommendation (short, prescriptive)
Start with OnPush, immutability, async pipe, and targeted NgZone.runOutsideAngular for noisy background work. Measure. If that still fails your performance goals and you understand the compatibility costs, progressively adopt a zone-less or Signals-driven approach. Do not remove Zone.js preemptively; remove it intentionally, backed by profiling and a migration plan.
Short and fierce: optimize only the hot paths. Keep the rest simple.
Further reading
- Angular change detection guide: https://angular.io/guide/change-detection
- Angular performance guide: https://angular.io/guide/performance
- NgZone API: https://angular.io/api/core/NgZone
- Zone.js: https://github.com/angular/zone.js
- Signals guide: https://angular.io/guide/signals



