Ch. 4 · Angular

Angular Zoneless Change Detection

Without zone.js, Angular renders only when notified. See which updates notify, which silently don't, and how TestBed catches the gaps for you.

~6 min readadvancedupdated Oct 19, 2026

For most of Angular’s history, you never had to tell the framework that something changed. zone.js patched setTimeout, promises, addEventListener, XMLHttpRequest and dozens of other browser APIs, and after any of them ran, Angular checked the whole component tree. That’s convenient and wasteful: a mousemove listener in a third-party widget triggers change detection for the entire app, whether or not any state changed.

Zoneless change detection drops zone.js. Angular no longer guesses when state might have changed; it waits to be told. Code that already tells it works unchanged. Code that relied on the zone noticing for it stops updating the screen, usually without any error. That second category is what this guide is about, because it’s what interviewers ask about and what bites teams during migration.

Versions and setup

Angular 18 introduced provideExperimentalZonelessChangeDetection(). Angular 20 renamed it provideZonelessChangeDetection(), and the API reference marks it stable from v20.2. From Angular 21, zoneless is the default for new applications, and you’d see provideZoneChangeDetection() only in apps that opted back in.

On Angular 20 you add the provider at bootstrap and then remove zone.js from the build:

bootstrapApplication(App, {
  providers: [provideZonelessChangeDetection(), provideBrowserGlobalErrorListeners()],
});
TypeScript

Delete zone.js and zone.js/testing from the polyfills arrays of both the build and test targets in angular.json (or from polyfills.ts), then npm uninstall zone.js. The global error listeners line is there because zone.js was also what forwarded uncaught async errors to your ErrorHandler; without it, they go straight to window.

What counts as a notification

Angular schedules a render when one of these happens:

  • a signal that a template reads is updated
  • ChangeDetectorRef.markForCheck() is called (the async pipe does this for you on each emission)
  • ComponentRef.setInput() is called
  • a listener bound in a template or a host binding runs, such as (click)="save()"
  • a view that was marked dirty by one of the above is attached

Anything else that mutates state is invisible. To see the difference concretely, here’s a component with one plain field, one signal and one BehaviorSubject rendered through async:

@Component({
  selector: 'app-probe',
  imports: [AsyncPipe],
  template: `
    <p>{{ plain }}</p>
    <p>{{ sig() }}</p>
    <p>{{ obs$ | async }}</p>
    <button (click)="plain = plain + 1">+1</button>
  `,
})
export class Probe {
  plain = 0;
  readonly sig = signal(0);
  readonly obs$ = new BehaviorSubject(0);
  readonly cdr = inject(ChangeDetectorRef);
}
TypeScript

Running it in a zoneless Angular 20 TestBed and awaiting fixture.whenStable() after each action, the three paragraphs read:

initial               0 0 0
plain in setTimeout   0 0 0
plain in promise      0 0 0
signal set            6 1 0
observable + async    6 1 2
plain + markForCheck  7 1 2
template click        8 1 2
Text

Read the first four rows carefully. Setting plain = 5 inside a setTimeout, then plain = 6 inside a promise callback, changed nothing on screen. Then the signal was set, Angular re-rendered the component, and plain jumped to 6, the latest value, along for the ride. That’s the dangerous part: in a real app, some unrelated signal or click often triggers a render shortly after, so the bug looks intermittent. “The countdown only updates when I move the mouse” is the classic report.

The last two rows show the two escape routes for plain fields: call markForCheck() after the change, or change it from a template listener, which notifies automatically.

A worked example: the session-timeout banner

Here’s code that worked for years under zone.js:

@Component({
  selector: 'app-session-timer',
  template: `<p>Your session expires in {{ secondsLeft }} seconds.</p>`,
})
export class SessionTimer implements OnInit, OnDestroy {
  secondsLeft = 120;
  private timer?: ReturnType<typeof setInterval>;

  ngOnInit() {
    this.timer = setInterval(() => this.secondsLeft--, 1000);
  }
  ngOnDestroy() {
    clearInterval(this.timer);
  }
}
TypeScript

With zone.js, each interval tick was a patched task, so Angular ran change detection every second and the number counted down. Zoneless, the field still decrements every second, but nothing tells Angular, so the banner sits at 120. If the user clicks somewhere, the click’s listener triggers a render and it jumps to, say, 87.

The fix is to make the state a signal:

export class SessionTimer {
  readonly secondsLeft = signal(120);

  constructor() {
    const timer = setInterval(() => this.secondsLeft.update((s) => s - 1), 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(timer));
  }
}
TypeScript

with {{ secondsLeft() }} in the template. Now each update is a notification, Angular schedules a render for this component, and only the views that read secondsLeft need refreshing. Calling markForCheck() inside the interval would also work, but signals keep the “what changed” information attached to the data instead of spreading notification calls through your code.

The same pattern shows up with WebSocket onmessage handlers, ResizeObserver callbacks, map or chart library events, IndexedDB callbacks and anything else that calls your code from outside Angular’s listeners.

Less obvious breakages

Reactive forms. setValue, patchValue and FormArray.push update the form and emit valueChanges, but they don’t schedule a render. If a template shows form.value.total and you patch the form from an HTTP callback, the view can stay stale. Read form state through an async pipe on valueChanges, convert it with toSignal(), or call markForCheck().

NgZone.onStable and friends. onMicrotaskEmpty, onUnstable and onStable never emit in a zoneless app, and isStable is always true. Code that waited for onStable to measure the DOM should use afterNextRender. NgZone.run() and runOutsideAngular() are harmless and can stay; removing them from a library can hurt apps that still use zone.js.

Server-side rendering. SSR needs to know when the app is “stable” enough to serialise. zone.js used to track every pending task; without it, Angular counts only what it knows about, such as pending router transitions and HttpClient requests. If you load data some other way before rendering, wrap it in inject(PendingTasks).run(async () => ...) so the server waits for it.

Finding these bugs before users do

You don’t have to audit every component by hand. In a zoneless TestBed (which is the default in recent versions), if a test sets a field and calls fixture.detectChanges() without any notification, Angular throws:

NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked.
Previous value: '8'. Current value: '100'. Expression location: Probe component.
Text

That error came from the probe above after component.plain = 100; fixture.detectChanges(). It’s deliberate: TestBed is telling you this component isn’t zoneless-compatible. In production code, fix the component with signals or markForCheck(). Prefer await fixture.whenStable() over detectChanges() in new tests, since it lets Angular decide whether a render is needed, just as the real app would.

Two more tools help during migration. Switching components to ChangeDetectionStrategy.OnPush first is a good rehearsal, because OnPush components already depend on the same notifications. And in development, provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) (developer preview in v20) periodically checks every view and reports bindings that changed without a notification.

Interview question: is zoneless just faster?

“Your lead says moving to zoneless is a free performance win: add one provider, remove one polyfill. Do you agree?”

Answer. It’s a real win but not free. The gains are fewer change detection passes (no global check after every patched event), no zone.js in the bundle (its minified build is about 39 KB, roughly 12 KB gzipped), cleaner stack traces, and native async/await instead of downlevelled code. The cost is that every place state changes outside a notification stops updating the screen, and it fails silently or intermittently. Before flipping it, I’d run the test suite with zoneless TestBed and fix every NG0100 it throws, move components to OnPush and signals where they mutate plain fields from timers, sockets or library callbacks, replace NgZone.onStable waits with afterNextRender, add PendingTasks around non-HTTP async work if we use SSR, and keep provideBrowserGlobalErrorListeners() so error reporting doesn’t drop. With that done, the performance gain is real and the app is easier to reason about, because every render has a cause you can point to.

Further reading

More in Angular

read ✓Angular · mid

Angular Pure and Impure Pipes

Why a pure pipe ignores array.push(), what pure: false costs per change detection pass, and why reading a signal inside transform goes stale.

~6 min readread →
esc