Zone.js Explained
Zone.js is the library that, for years, made Angular’s change detection feel automatic. You update a property inside a click handler or after an HTTP call, and the view just updates — no manual wiring required. The magic behind that convenience is monkey-patching: Zone.js wraps the browser’s asynchronous APIs so Angular knows the exact moment something might have changed. Understanding how it works explains both why Angular is so ergonomic and why it can sometimes do more work than necessary.
What a zone actually is
A zone is an execution context that persists across asynchronous tasks. Think of it as a wrapper that follows your code through setTimeout, promises, and event callbacks, so that work scheduled in one zone is still associated with that zone when it eventually runs. Angular creates a single special zone called the NgZone, and it runs your entire application inside it.
The key insight is that normal async callbacks lose their context — a setTimeout callback has no idea who scheduled it. Zone.js fixes that by intercepting the scheduling. When an async task is created inside a zone, Zone.js remembers the zone and restores it when the task fires.
import { NgZone, inject, Component } from '@angular/core';
@Component({
selector: 'app-demo',
standalone: true,
template: `<button (click)="load()">Load</button>`,
})
export class DemoComponent {
private zone = inject(NgZone);
load() {
// This callback runs back inside the Angular zone,
// so change detection fires automatically afterward.
setTimeout(() => console.log('zone:', NgZone.isInAngularZone()), 100);
}
}
Output:
zone: true
How monkey-patching works
When Zone.js loads (Angular imports it via polyfills before bootstrap), it replaces — monkey-patches — the global async APIs with instrumented versions. The original function is kept, and the patched wrapper notifies the zone whenever a task starts and finishes.
The patched surface is broad. It covers timers, DOM events, network calls, and the microtask queue:
| Category | Patched APIs |
|---|---|
| Timers | setTimeout, setInterval, requestAnimationFrame |
| Microtasks | Promise.then, queueMicrotask, MutationObserver |
| Events | addEventListener (clicks, input, scroll, etc.) |
| Network | XMLHttpRequest, fetch |
| Other | WebSocket, Geolocation, FileReader |
Each patched API reports into the zone’s task lifecycle. NgZone tracks how many tasks are outstanding and exposes an observable, onMicrotaskEmpty, that emits when the microtask queue drains. That signal is what Angular subscribes to in order to run change detection. In other words: Angular doesn’t watch your data — it watches the clock of async activity and re-checks the component tree whenever that activity settles.
// Simplified version of what Angular does internally at bootstrap.
this.ngZone.onMicrotaskEmpty.subscribe(() => {
this.tick(); // run change detection over the whole component tree
});
Because Angular re-checks the tree after any async task completes — not just the one that changed your data — a single stray
setIntervalcan keep change detection running constantly. This is a common cause of mysterious CPU usage.
Running code outside the zone
Sometimes you have high-frequency async work — animation loops, mouse-move tracking, third-party widgets — that should not trigger change detection on every tick. NgZone.runOutsideAngular executes a callback in a zone that Angular ignores, and run re-enters when you genuinely need an update.
import { NgZone, inject, signal, Component } from '@angular/core';
@Component({
selector: 'app-tracker',
standalone: true,
template: `<p>Moves: {{ moves() }}</p>`,
})
export class TrackerComponent {
private zone = inject(NgZone);
moves = signal(0);
start() {
this.zone.runOutsideAngular(() => {
document.addEventListener('mousemove', () => {
const next = this.moves() + 1;
// Only re-enter every 100th event to render.
if (next % 100 === 0) {
this.zone.run(() => this.moves.set(next));
} else {
this.moves.set(next);
}
});
});
}
}
The overhead — and the move toward zoneless
Zone.js is not free. It ships roughly 13 KB gzipped, it adds an indirection layer around every async call, and — most significantly — it makes change detection coarse: any async event re-checks the entire component tree (unless you opt into OnPush). The patching itself can also interfere with some libraries that depend on the native, unpatched APIs.
Modern Angular (16+) introduced signals, which track reactivity at the value level instead of inferring it from async timing. Angular 18 added experimental zoneless change detection, and Angular 19+ continues to stabilize it. With signals plus provideZonelessChangeDetection(), Angular can update only the components whose signals actually changed, and you can drop the Zone.js polyfill entirely.
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
}).catch((err) => console.error(err));
| Aspect | Zone.js | Zoneless + signals |
|---|---|---|
| Trigger | Any async task completes | A signal/template binding changes |
| Granularity | Whole tree (or OnPush subtrees) | Components touched by changed signals |
| Bundle cost | ~13 KB polyfill | No polyfill |
| Setup | Default | provideZonelessChangeDetection() |
Best practices
- Treat Zone.js as an implementation detail; write components against signals and
OnPushrather than relying on whole-tree checks. - Wrap high-frequency listeners (mousemove, scroll, animation frames) in
runOutsideAngularand re-enter withrunonly when you need a render. - Avoid leaving
setIntervalor recurring timers running inside the zone — they keep change detection alive and drain CPU and battery. - Prefer
signal,computed, andeffectfor state, so your reactivity survives a future migration to zoneless. - When debugging “why did change detection run?”, check for stray patched async calls before blaming your own code.
- For new apps on Angular 19+, evaluate
provideZonelessChangeDetection()to ship smaller bundles and finer-grained updates.