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());
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:
-
Template event handlers like
(click)→ marks the relevant view tree dirty -
Signal writes (
signal.set()/.update()) → refresh dependent views -
AsyncPipeemissions → marks view dirty on emission -
Angular-managed subsystems
- Router navigations
- HttpClient observables
- Forms
-
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(),
],
});
Removing Zone.js from the build:
- Remove
zone.jsimport frompolyfills.ts(or the equivalentangular.json“polyfills” entry). - Remove
zone.js/testingfrom 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); }
}
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}`));
}
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 });
}
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
}
Fix A: use a signal
count = signal(0);
ngOnInit() {
setInterval(() => this.count.update(c => c + 1), 1000); // updates UI
}
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);
}
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
-
postMessagehandlers - custom schedulers /
requestAnimationFrameloops
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
};
}
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();
};
}
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));
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.OnPushbroadly - 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');
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();
});
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/awaitwith 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');
});
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.
-
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
- Search for:
-
Adopt explicit reactivity
- Convert local state to
signal() - Convert RxJS subscriptions to
AsyncPipeortoSignal()when feasible
- Convert local state to
-
Standardize manual notification
- For unavoidable imperative updates: end the callback with
cdr.markForCheck() - Avoid sprinkling
ApplicationRef.tick()unless you intentionally want full-app checks
- For unavoidable imperative updates: end the callback with
-
Enable zoneless change detection
- Add the Zoneless provider at bootstrap
- Remove Zone.js from polyfills
- Verify: navigation, forms, http, animations (if used)
-
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().
- Events emitted outside Angular template bindings may not mark views dirty unless wired through Angular bindings or paired with
-
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 ormarkForCheck().
-
-
Libraries that assume patched globals
- Some older libs may import
zone.jsimplicitly or depend on zone semantics—validate compatibility.
- Some older libs may import
-
Performance trap
- Replacing zone-driven global ticks with frequent manual
detectChanges()can be worse. - Prefer signals /
markForCheck()/ AsyncPipe updates (which Angular can coalesce).
- Replacing zone-driven global ticks with frequent manual
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)