DEV Community

Cover image for Angular Zoneless: Migrating off Zone.js without breaking your UI
Hulpoi George
Hulpoi George

Posted on

Angular Zoneless: Migrating off Zone.js without breaking your UI

You know that feeling when you change a value in a callback… and Angular “magically” updates the UI?

Until it doesn’t.

That “magic” has historically been Zone.js. And as Angular moves toward Zoneless change detection, a lot of apps are discovering just how much they were relying on incidental change detection ticks.

This post is a practical, dev-to-dev walkthrough of:

  • what Zone.js did for Angular
  • what replaces it in an Angular Zoneless app
  • what breaks during migration (and why)
  • patterns that “just work” (signals / AsyncPipe)
  • how to handle the biggest hotspot: third‑party callbacks
  • testing gotchas
  • a step-by-step migration plan you can actually follow

Problem: Zone.js made change detection feel effortless

What Zone.js did for Angular (baseline to replace)

Zone.js monkey-patches async browser APIs:

  • Promise
  • setTimeout / setInterval
  • addEventListener
  • XHR / fetch-like APIs
  • …and more

Angular’s NgZone listens to Zone.js’ “the microtask queue is empty” signal and responds by running a global change detection pass:

// Pseudocode of the zone-driven model
ngZone.onMicrotaskEmpty.subscribe(() => appRef.tick());
Enter fullscreen mode Exit fullscreen mode

So any async work—anywhere—could eventually trigger an Angular refresh.


Agitation: That “magic tick” was also a tax (and a source of bugs)

Zone-based change detection has a couple of real-world pain points:

  • Hard-to-predict Change Detection triggers
    • A third-party SDK schedules a timer → you get a full app check.
    • Some unrelated listener fires → another tick.
  • Performance overhead
    • Patching async APIs isn’t free.
    • Extra change detection passes add up, especially in large apps.
  • Stability is tricky
    • Tests/SSR often depend on “is the zone stable yet?” semantics.
    • It can be hard to reason about what “stable” even means when everything is tracked.

In other words: Zone.js often made things work, but not always in ways you’d intentionally design.


Solution: Angular Zoneless change detection is explicit and framework-driven

Core idea

In an Zoneless app, Angular moves away from global async patching and toward:

  • known framework entry points (template events, router, http, forms)
  • explicit reactive state updates (signals, AsyncPipe emissions)

Instead of “tick after any async completes”, Angular uses a change detection scheduler to queue/coalesce updates (often to a microtask or animation frame).

What triggers UI refresh in a Zoneless app?

Primary trigger sources:

  1. Template event handlers like (click) → marks the relevant view tree dirty
  2. Signal writes (signal.set() / .update()) → refresh dependent views
  3. AsyncPipe emissions → marks view dirty on emission
  4. Angular-managed subsystems
    • Router navigations
    • HttpClient observables
    • Forms
  5. Manual triggers for “unknown async”
    • third-party callbacks
    • raw timers
    • custom DOM listeners

This is the heart of Angular Zoneless: Angular updates when Angular knows something changed.


Enabling Zoneless mode (bootstrap-level)

The API name has changed across versions while the feature matured. In Angular versions where it is still flagged as experimental/dev-preview, it is typically exposed as provideExperimentalZonelessChangeDetection() from @angular/core. Use the exact function name provided by your installed Angular version.

Standalone bootstrap example:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection(),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Removing Zone.js from the build:

  • Remove zone.js import from polyfills.ts (or the equivalent angular.json “polyfills” entry).
  • Remove zone.js/testing from test setup if you intend to run tests without Zone (more on that later).

Patterns that “just work” in Zoneless apps (and why)

Works: signals (preferred primitive)

Signals are first-class in zoneless because writes are explicit.

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <button (click)="inc()">+</button>
    <p>Count: {{ count() }}</p>
  `,
})
export class CounterComponent {
  count = signal(0);
  inc() { this.count.update(c => c + 1); }
}
Enter fullscreen mode Exit fullscreen mode

Why this works: Angular knows exactly when count changes and can schedule refresh precisely.


Works: AsyncPipe (RxJS is still totally fine)

AsyncPipe marks the view dirty on emission (zoneless-friendly).

import { Component } from '@angular/core';
import { interval, map } from 'rxjs';

@Component({
  template: `{{ value$ | async }}`,
})
export class TickerComponent {
  value$ = interval(1000).pipe(map(n => `t=${n}`));
}
Enter fullscreen mode Exit fullscreen mode

Works: toSignal() bridge (RxJS → signals)

This avoids manual subscriptions and keeps updates explicit.

import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';

@Component({
  template: `
    <ng-container *ngIf="user(); else loading">
      {{ user()!.name }}
    </ng-container>
    <ng-template #loading>Loading...</ng-template>
  `,
})
export class UserComponent {
  private http = inject(HttpClient);

  user = toSignal(this.http.get<{name: string}>('/api/me'), { initialValue: null });
}
Enter fullscreen mode Exit fullscreen mode

Common breakage: “unknown async” stops updating your UI

Symptom

State updates happen, but UI doesn’t refresh because Angular wasn’t notified.

This is the migration moment where people say: “Wait… Angular is broken.”

It isn’t. You just removed the global “tick whenever anything async finishes” mechanism.


Case 1: Raw timers mutating plain fields

Bad (in zoneless):

count = 0;

ngOnInit() {
  setInterval(() => this.count++, 1000); // UI may not update
}
Enter fullscreen mode Exit fullscreen mode

Fix A: use a signal

count = signal(0);

ngOnInit() {
  setInterval(() => this.count.update(c => c + 1), 1000); // updates UI
}
Enter fullscreen mode Exit fullscreen mode

Fix B: manual notification (markForCheck())

private cdr = inject(ChangeDetectorRef);
count = 0;

ngOnInit() {
  setInterval(() => {
    this.count++;
    this.cdr.markForCheck(); // schedules check for this component subtree
  }, 1000);
}
Enter fullscreen mode Exit fullscreen mode

A quick warning: detectChanges() is synchronous and can be expensive or unsafe in some lifecycle moments. In zoneless apps, markForCheck() is usually the right “schedule it” primitive.


Third-party libraries and DOM callbacks (the biggest migration hotspot)

This is where most real apps feel the pain first.

The problem class

Libraries that call you back from:

  • native DOM listeners they register themselves
  • WebSocket callbacks
  • postMessage handlers
  • custom schedulers / requestAnimationFrame loops

In zone-based Angular, those callbacks often triggered Change Detection “by accident” due to patching.

In Angular Zoneless, Angular won’t notice unless you tell it.


Strategies (pick one) for third-party callbacks

Option 1: Write to signals inside the callback (best)

data = signal<string | null>(null);

connect(ws: WebSocket) {
  ws.onmessage = (ev) => {
    this.data.set(ev.data); // signal write -> schedules refresh
  };
}
Enter fullscreen mode Exit fullscreen mode

This is the cleanest migration path because it turns “random callback” into “explicit state update”.


Option 2: Manual markForCheck() for plain fields

Useful when refactoring to signals isn’t feasible yet.

private cdr = inject(ChangeDetectorRef);
latest: string | null = null;

connect(ws: WebSocket) {
  ws.onmessage = (ev) => {
    this.latest = ev.data;
    this.cdr.markForCheck();
  };
}
Enter fullscreen mode Exit fullscreen mode

Option 3: Wrap callbacks into Angular-managed reactive sources

Convert callbacks to RxJS and bind via AsyncPipe / toSignal():

const messages$ = fromEvent<MessageEvent>(ws, 'message').pipe(map(e => e.data));
Enter fullscreen mode Exit fullscreen mode

One pitfall to keep in mind: teardown. Make sure you unsubscribe/complete properly. Zone removal doesn’t create leaks—but it can remove “incidental” stability behavior you may have been leaning on.


Change detection strategy guidance for Zoneless apps

A practical mental shift:

  • OnPush becomes the default model
  • Change Detection should run because:
    • an input changed
    • an event happened
    • a signal changed
    • an observable emitted

Recommendations:

  • Use ChangeDetectionStrategy.OnPush broadly
  • Prefer signals for local UI state
  • Prefer AsyncPipe / toSignal() for streams

Pitfall to watch for:

  • in-place mutations like this.model.items.push(...) + OnPush can hide updates unless:
    • the mutation is behind a signal write, or
    • you call markForCheck()

Router / HttpClient / Forms considerations

HttpClient

Usually fine because it’s RxJS-based.

// OK with AsyncPipe
data$ = this.http.get<Data>('/api/data');
Enter fullscreen mode Exit fullscreen mode

If you subscribe() manually and mutate plain fields, you must notify:

this.http.get<Data>('/api/data').subscribe(d => {
  this.data = d;
  this.cdr.markForCheck();
});
Enter fullscreen mode Exit fullscreen mode

Router

Router outlet activation and navigations are Angular-controlled, so it should integrate with Zoneless scheduling. The tricky part is often your side effects (analytics SDKs, third-party callbacks, etc.) that happen during navigation.

Forms

Template-driven and reactive forms are Angular-managed; UI updates should occur.

Main pitfall: custom controls that update via non-Angular async callbacks must call onChange correctly and/or markForCheck() when needed.


Testing pitfalls (Zone removal is most visible here)

This is where angular Zoneless tends to surface the most surprises.

Historically, Angular testing utilities depended on Zone.js:

  • fakeAsync(), tick(), flush()
  • waitForAsync()
  • fixture.whenStable()

In a fully Zoneless test environment:

  • fakeAsync-style tests can’t work without a zone-like task interceptor.
  • Prefer:
    • async/await with explicit timer mocking (Jest/Vitest fake timers), or
    • signal-driven tests that don’t require “stability” tracking

Example with Jest fake timers:

it('updates after timer', () => {
  jest.useFakeTimers();

  const fixture = TestBed.createComponent(CounterComponent);
  fixture.detectChanges();

  jest.advanceTimersByTime(1000);
  fixture.detectChanges(); // explicit

  expect(fixture.nativeElement.textContent).toContain('Count: 1');
});
Enter fullscreen mode Exit fullscreen mode

Important pitfall: if you keep Zone.js in tests but remove it in production, Zone may mask missing markForCheck() calls. Run at least a subset of CI tests zoneless to catch these.


Migration blueprint: step-by-step (minimize risk)

This is the order I’d recommend if you’re migrating a real production app.

  1. Audit dependencies on “incidental ticks”

    • Search for:
      • setTimeout, setInterval, requestAnimationFrame
      • raw addEventListener
      • WebSocket callbacks
      • third-party SDK callbacks
    • Find places mutating component fields without signals/AsyncPipe/markForCheck
  2. Adopt explicit reactivity

    • Convert local state to signal()
    • Convert RxJS subscriptions to AsyncPipe or toSignal() when feasible
  3. Standardize manual notification

    • For unavoidable imperative updates: end the callback with cdr.markForCheck()
    • Avoid sprinkling ApplicationRef.tick() unless you intentionally want full-app checks
  4. Enable zoneless change detection

    • Add the Zoneless provider at bootstrap
    • Remove Zone.js from polyfills
    • Verify: navigation, forms, http, animations (if used)
  5. Fix tests

    • Remove zone-based helpers or keep a legacy suite
    • Introduce fake timers and explicit detectChanges()
    • Add regression tests around third-party integrations

Edge cases & gotchas worth calling out

  • Custom elements / Web Components
    • Events emitted outside Angular template bindings may not mark views dirty unless wired through Angular bindings or paired with markForCheck().
  • Direct DOM manipulation
    • If you update DOM outside Angular templates, Change Detection doesn’t matter.
    • But if you update component state from DOM observers (MutationObserver, ResizeObserver), you must notify Angular.
  • Microtask chains
    • somePromise.then(() => this.x=1) won’t auto-tick anymore. Use signals or markForCheck().
  • Libraries that assume patched globals
    • Some older libs may import zone.js implicitly or depend on zone semantics—validate compatibility.
  • Performance trap
    • Replacing zone-driven global ticks with frequent manual detectChanges() can be worse.
    • Prefer signals / markForCheck() / AsyncPipe updates (which Angular can coalesce).

Closing thought

The best way to think about Angular Zoneless is: Angular didn’t stop doing change detection—it stopped guessing when you wanted it.

Once you migrate the “unknown async” hotspots to signals/AsyncPipe/markForCheck(), the app tends to become:

  • more predictable
  • easier to reason about
  • and often faster under load

If you want, share a snippet of a component that “stops updating” after removing Zone.js (timers, websockets, SDK callbacks, etc.) and I’ll show the smallest zoneless-friendly fix.

Top comments (0)