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.

Change detection cycles during 10 seconds of dashboard use Bar chart comparing the number of change detection cycles in a dashboard with zone.js and zoneless change detection. Change detection cycles during 10 seconds of dashboard use zone.js (polling, scroll, analytics) 640cycles zone.js + runOutsideAngular fixes 180cycles Zoneless with signals 42cycles

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

typescript
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.

Zoneless migration path Steps for migrating an Angular application to zoneless change detection. Zoneless migration path Signals component state OnPush all components Fix implicit markForCheck / async Zoneless provider + remove zone.js Verify tests + profiling

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.

Updating patterns for zoneless Code patterns that rely on zone.js and their zoneless-compatible replacements. Updating patterns for zoneless Pattern with zone.js Problem without zones Replacement this.value = x in setTimeout view not refreshed signal.set(x) Subscription updating a field view not refreshed async pipe or toSignal Third-party callback mutating state view not refreshed signal or markForCheck NgZone.onStable waits never fires afterNextRender

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.