Going Zoneless in Angular
This guide is part of Angular Performance, within Framework Performance. zone.js has powered Angular's change detection since the beginning: it patches browser APIs so Angular knows when any async work completes, then runs change detection to update the UI. It is convenient — mutate any property and the view updates — but it has costs. Every async callback, including ones that change nothing (scroll listeners, polling timers, third-party analytics), triggers a change detection cycle. zone.js adds bundle size, patches many APIs, and makes stack traces harder to read.
Zoneless change detection removes zone.js. Angular schedules change detection only when it is told something changed: a signal used in a template updates, an event bound in a template fires, markForCheck is called, or the async pipe receives a value. Applications that already use signals and OnPush can often switch with little work; applications that rely on zone.js to notice arbitrary mutations need migration first.
Rapid Diagnosis
- Count change detection cycles with the Angular DevTools profiler during typical use.
- Check for state mutations outside signals (plain class properties updated in callbacks).
- Check component strategies: default change detection components rely on zone-triggered checks.
- Check third-party libraries for dependencies on zone.js behaviour.
Root Cause Analysis
1. Zone-triggered cycles. Unrelated async work triggers change detection.
2. Implicit updates. Code mutates properties and relies on zone.js to refresh views.
3. Default change detection. Components checked on every cycle.
4. Library assumptions. Some libraries expect NgZone.onStable or zone-based behaviour.
Step-by-Step Resolution
1. Move component state to signals
import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-cart-summary',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ count() }} items · {{ total() | currency }}</p>`,
})
export class CartSummary {
items = signal<{ price: number; qty: number }[]>([]);
count = computed(() => this.items().reduce((n, i) => n + i.qty, 0));
total = computed(() => this.items().reduce((s, i) => s + i.price * i.qty, 0));
}
// trade-off: signals require updating state through set/update instead of direct
// mutation; the explicitness is what lets Angular update only affected views.
2. Make all components OnPush
OnPush surfaces places where views depend on implicit updates; fix them before going zoneless.
3. Replace implicit updates
Where code updates plain properties in callbacks (timers, subscriptions, third-party events), switch to signals, use the async pipe, or call ChangeDetectorRef.markForCheck().
4. Enable zoneless and remove zone.js
Add the zoneless provider (its name depends on the Angular version), remove zone.js from polyfills in angular.json, and run tests and manual checks.
Verification
Profile the same interactions with Angular DevTools: change detection cycles should be far fewer and shorter. Check bundle size: zone.js removal saves its code. Test flows that rely on timers, websockets and third-party callbacks to ensure views update. In the field, compare INP for key interactions.
Worked Example: A Trading Dashboard
A trading dashboard received price updates over a websocket ten times per second and had scroll-linked analytics, a polling notifications service and a chart library with internal timers. With zone.js and mostly default change detection, the app ran about 60 change detection cycles per second, each 6–12ms, leaving little main-thread time for user input; INP p75 was 380ms. The team converted price state to signals, made all components OnPush, moved chart and analytics callbacks outside the zone as a first step, and then went zoneless. Change detection dropped to a handful of cycles per second, limited to the price cells that changed. INP p75 fell to 110ms, and CPU usage on low-end laptops dropped by half.
Testing Strategy for Zoneless
Unit tests that relied on fixture.detectChanges() and zone-based async helpers (fakeAsync, waitForAsync) may need updates: in zoneless tests, use await fixture.whenStable() and make sure state changes go through signals. End-to-end tests are the best safety net, because they catch views that no longer update after the switch. Run them against a zoneless build before release, paying attention to flows driven by timers, websockets and third-party widgets.
Auditing Code Before the Switch
Before enabling zoneless, find code that depends on zone.js. Search for property assignments inside subscribe callbacks, setTimeout/setInterval callbacks, promise then handlers and third-party library event handlers, where the component is not OnPush with signals. Each is a place where the view might stop updating. Also search for NgZone usage: onStable, onMicrotaskEmpty and run calls usually indicate zone-dependent logic that needs a different approach (afterNextRender, signals or explicit markForCheck). Running the app with all components set to OnPush for a while before going zoneless surfaces most of these issues in a safer setting.
Measuring the Gain
Record the same user flows before and after with the Angular DevTools profiler: the number of change detection cycles and their total duration. In the browser Performance panel, compare main-thread time during typical interactions and idle periods (zone.js-triggered cycles from timers and polling often run even when users are idle). In the field, compare INP p75 for the routes that changed, and check the bundle size difference from removing zone.js.
Common Mistakes
- Switching before migrating state. Views stop updating.
- Leaving default change detection. Hides implicit dependencies until zoneless breaks them.
- Forgetting third-party callbacks. UI stops reflecting library events.
- Relying on NgZone.onStable. It does not fire without zones; use
afterNextRender.
Edge Cases
Libraries requiring zone.js. Some older libraries assume zones; check compatibility or keep zone.js until replaced.
Hybrid apps. Partial migration is possible with OnPush and signals while zone.js remains; go zoneless at the end.
SSR. Zoneless works with SSR; ensure the app becomes stable for rendering by using pending tasks for async work.
Forms. Reactive forms work zoneless; ensure value changes that affect views go through signals or the async pipe.
FAQ
What does zone.js do in Angular?
It patches browser async APIs so Angular knows when async work completes and can run change detection automatically.
What are the benefits of zoneless?
Fewer and more targeted change detection cycles, smaller bundles, simpler stack traces and better interoperability with other code.
Do I need signals to go zoneless?
Signals are the most natural fit. The async pipe and explicit markForCheck also notify Angular of changes.
Is zoneless stable?
It has been progressing from experimental to stable across Angular versions; check your version's documentation for its status and provider name.
How much faster is zoneless?
It depends on how many unnecessary cycles zone.js caused. Apps with frequent async activity and large trees see the largest improvements.
Can I go zoneless incrementally?
You can migrate components to signals and OnPush incrementally; the zoneless switch itself is app-wide.
What replaces NgZone.runOutsideAngular?
Without zones, it is not needed: async work does not trigger change detection unless it updates signals or marks views.
Does zoneless affect hydration?
Hydration works with zoneless. Make sure the app signals when it is stable (pending tasks), so SSR waits for async work correctly.
Does zoneless reduce bundle size?
Yes, by removing zone.js from the polyfills. The exact saving depends on the version, but it is typically tens of kilobytes uncompressed.
Do I still need OnPush with zoneless?
OnPush remains a good default: it limits which components are checked when change detection runs, complementing signal-driven scheduling.
Can libraries still use timers?
Yes. Without zones, their timers simply do not trigger change detection unless they update signals or mark views.
Related
- Angular performance — the topic overview.
- Deferring components with Angular @defer blocks — smaller initial bundles.
- Cutting Vue reactivity cost with shallowRef — reactivity trade-offs in Vue.