Zoneless change detection
`zone.js` monkey-patched `setTimeout`, `addEventListener`, `XMLHttpRequest` and promises, then told Angular to check everything. It is gone, and the framework knows what changed from signals and the template instead.
What zone.js was doing
zone.js wrapped the browser's async APIs so that every macrotask and microtask ran inside a zone that Angular could observe. When any of them completed, Angular assumed something might have changed and ran change detection over the whole component tree, from the root down.
That design explains almost every piece of advice you remember:
OnPushwas an optimisation you applied by hand, because the default checked everything.ChangeDetectorRef.detectChanges()/markForCheck()existed to nudge the framework when you had stepped outside a zone — arunOutsideAngular, a WebSocket callback, a third-party library using unpatched APIs.asyncpipe was recommended partly because it marked its own component for check, saving you from a manual nudge.- "Expression changed after it was checked" was a zone-era error: a second pass happened because something changed during the first one.
None of that is wrong history. It is just no longer how the framework works.
How it works now
Two things tell Angular when to re-render:
- A signal changed — and the framework knows exactly which template reads it.
- Something happened in a template — an event binding fired, or an input was written by the framework (a router param, a host binding).
Because the dependency is recorded when the template reads the signal, there is no tree walk to find a change: the component that depends on the signal is the thing that is notified. This is why the new component default is OnPush in v22 — it is not an optimisation you opt into any more, it is the semantics of the framework.
What breaks in a zoneless app
The failures are all the same shape: a value that a template reads is updated outside the signal graph.
@Component({
selector: 'app-clock',
template: `<p>{{ now }}</p>`, // never updates anything
})
export class Clock {
now = '';
constructor() {
setInterval(() => { this.now = new Date().toLocaleTimeString(); }, 1000);
}
}Under zone.js that worked by accident: the interval callback ran inside the zone, Angular checked everything, and the template picked up the new string. Zoneless, nothing tells Angular to look. The fix is the point of the whole redesign:
export class Clock {
readonly now = signal(new Date().toLocaleTimeString());
constructor() {
setInterval(() => this.now.set(new Date().toLocaleTimeString()), 1000);
}
}The other common failure is a third-party library that mutates DOM or component state from a callback the framework cannot see. Where you cannot convert the value into a signal, the escape hatch still exists — ChangeDetectorRef.markForCheck() after an OnPush component, or ApplicationRef.tick() as a blunt instrument — but reaching for it is a signal (pun intended) that some state should have been a signal.
The migration trap is the opposite of what you would expect: code that looks fine because it happened to run during a template event. If a value is set in a click handler, change detection runs and the template updates — so the bug only shows up when the same value is set from a timer, a websocket, or a promise. If you are porting an app, grep for setTimeout, setInterval, subscribe( and addEventListener in components first; those are where the missing signal will be.
What breaks in a zoneless app
One timer callback writes to a signal and to a plain field. Only the signal is rendered — press the third button to watch the field catch up when something else triggers a view update.
a signal, owned by its own component:
a plain field, owned by a different component:
Start the interval: the same callback writes to a signal and to a plain field. The signal-backed component is re-rendered every tick; the component holding the plain field is not — it has nothing to mark it dirty, and since v22 the default is OnPush, so nobody re-checks it.
The field really did change in memory. Its own button is an ordinary event in its own template, and that is enough to get it checked — press it and the frozen number jumps to catch up.
Strict checking in development
Zoneless apps can run the old "check twice and compare" safety net in development:
providers: [
provideZonelessChangeDetection(),
provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }),
]exhaustive: true verifies every binding after each check, not just the ones in the current view, and surfaces the modern equivalent of "expression changed after it was checked": a template is reading something that was written during change detection. Leave it on in development; it is the cheapest way to find state that escaped the signal graph.
Zoneless is the default now
Since v21, a project created with the CLI is zoneless: no zone.js polyfill in angular.json, no provideZoneChangeDetection, and provideZonelessChangeDetection in the config (it is the default, so you may not even see it written out). If you want the old behaviour temporarily while porting, you can still add provideZoneChangeDetection(), but treat it as a migration crutch with a deadline rather than a resting state.
What to take away
Zoneless is not a performance flag you flip; it is the reason signals matter. Once the framework derives its work from what the template reads, "I updated a field and the page did not change" stops being a mystery and becomes a missing signal(). The practical consequences for how fast the app feels are on the next page, Performance.