Zoneless Change Detection
For most of its life Angular has relied on Zone.js to know when something might have changed. Zoneless change detection removes that dependency entirely: instead of monkey-patching every async API, Angular runs change detection only when something explicitly tells it the UI is dirty — primarily reading and updating signals. The result is smaller bundles, fewer surprise re-renders, cleaner stack traces, and a path toward fully reactive Angular applications.
Why go zoneless?
Zone.js works by patching setTimeout, addEventListener, Promise, XMLHttpRequest, and dozens of other browser APIs so that Angular is notified whenever any of them fire. That notification triggers a full check of the component tree. It is convenient — you never call anything manually — but it is also expensive, opaque, and the cause of many “why did this run again?” debugging sessions.
Zoneless mode flips the model. Angular only schedules change detection when one of a small set of explicit signals occurs:
- A signal read in a template changes value.
- A component or
@Input()/ model input is updated. - An event listener bound in a template fires.
ChangeDetectorRef.markForCheck()is called.- An
AsyncPipe,@if,@for, or other template binding marks the view dirty.
Because the triggers are explicit and finite, Angular can coalesce and schedule them efficiently, and you can reason precisely about every render.
| Aspect | Zone.js (classic) | Zoneless |
|---|---|---|
| Bundle size | Includes ~30 KB Zone.js | No Zone.js shipped |
| Trigger | Any patched async API | Signals / explicit notify |
| Mental model | Implicit, global | Explicit, local |
| Stack traces | Polluted by zone frames | Clean |
| Interop with non-Angular libs | Automatic | May need markForCheck() |
Enabling zoneless mode
Zoneless is provided through provideZonelessChangeDetection() (still labelled experimental as provideExperimentalZonelessChangeDetection in some releases — both register the same provider). Add it to your application config and remove the Zone.js polyfill.
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
],
};
bootstrapApplication(AppComponent, appConfig);
Then delete the zone.js import from polyfills (or angular.json) so it is no longer bundled:
// angular.json — before
"polyfills": ["zone.js"],
// after
"polyfills": []
Removing the Zone.js polyfill is what actually shrinks the bundle. Leaving it in while calling
provideZonelessChangeDetection()still works, but you pay for code you no longer use.
Writing components for a zoneless world
In zoneless mode, signals are how you tell Angular the view changed. Reading a signal in the template subscribes that view to the signal; updating the signal marks the view dirty. No setTimeout or Promise will trigger anything on its own.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<button (click)="increment()">Count: {{ count() }}</button>
@if (isEven()) {
<p>The count is even.</p>
}
`,
})
export class CounterComponent {
protected readonly count = signal(0);
protected readonly isEven = computed(() => this.count() % 2 === 0);
increment(): void {
this.count.update((n) => n + 1);
}
}
The click is a template-bound event, so Angular schedules a check; count() and isEven() are read in the template, so the update is reflected. This component is fully reactive with zero extra ceremony.
Handling external async work
The one place to be deliberate is state that changes outside Angular’s awareness — a WebSocket message, a third-party widget callback, or a manually created setTimeout. If you mutate a signal in those callbacks, the signal itself notifies Angular and you are fine:
import { Component, signal, inject, DestroyRef } from '@angular/core';
@Component({
selector: 'app-ticker',
template: `<p>Price: {{ price() }}</p>`,
})
export class TickerComponent {
protected readonly price = signal(0);
constructor() {
const socket = new WebSocket('wss://example.com/prices');
socket.onmessage = (e) => this.price.set(Number(e.data)); // signal notifies CD
inject(DestroyRef).onDestroy(() => socket.close());
}
}
If you are stuck holding a plain class field instead of a signal, call ChangeDetectorRef.markForCheck() after mutating it:
import { ChangeDetectorRef, inject } from '@angular/core';
private cdr = inject(ChangeDetectorRef);
onLegacyCallback(value: number): void {
this.legacyValue = value;
this.cdr.markForCheck(); // tell Angular to re-check this view
}
Verifying it works
A quick way to confirm you are truly zoneless is to check for Zone.js at runtime:
console.log('NgZone is zoneless:', (window as any).Zone === undefined);
Output:
NgZone is zoneless: true
You can also inspect the injected NgZone — in zoneless mode Angular provides a NoopNgZone, so any code relying on NgZone.onMicrotaskEmpty will simply never emit.
Best Practices
- Model component and shared state with signals so change detection is driven automatically and you rarely touch
markForCheck(). - Prefer
OnPush-friendly patterns everywhere; zoneless effectively makes the whole appOnPush, so impure code that mutated objects in place will stop updating. - Replace RxJS-into-template plumbing with
toSignal()so async streams notify the view through signals. - When integrating non-Angular libraries (charts, maps, sockets), funnel their callbacks into signals or call
markForCheck()explicitly. - Always remove the
zone.jspolyfill from the build — that is where the bundle and startup savings come from. - Migrate incrementally: enable zoneless in a feature build, run your e2e suite, and watch for views that stop updating (a tell-tale sign of zone-dependent code).