In a zone-based Angular app, how does zone.js tell Angular to run change detection after a setTimeout callback or a click handler?
answer
- monkey-patched browser APIs
- the Angular zone
- microtask queue drained
- one app-wide check
- knows when, not what
basics
~10 szone.js patches async browser APIs such as timers, promises and event listeners, so NgZone sees each callback run in the Angular zone; when its microtask queue drains, NgZone emits onMicrotaskEmpty and Angular calls ApplicationRef.tick().
solid answer
~40 s`zone.js` monkey-patches the browser's asynchronous APIs: `setTimeout` and `setInterval`, `Promise`, `addEventListener`, XHR, `fetch`, `requestAnimationFrame` and others. Angular runs the app inside a zone it forks, the **Angular zone**, wrapped by the `NgZone` service. Each patched callback that runs there enters and leaves that zone, and when it leaves with no microtasks pending, `NgZone` emits `onMicrotaskEmpty`. Angular's zone scheduler subscribes to that event and calls `ApplicationRef.tick()`, which checks the component tree from the root. So an `Eager` countdown component that decrements a field in `setTimeout` refreshes with no explicit call. The key limit: zone.js knows **that** async work finished, not **whether** state changed, so it triggers checks after every patched callback. Since v21 this only happens if the app adds `provideZoneChangeDetection()`; zoneless is the default.
code
ts · 7 linesimport { bootstrapApplication } from '@angular/platform-browser';
import { provideZoneChangeDetection } from '@angular/core';
import { App } from './app/app';
bootstrapApplication(App, {
providers: [provideZoneChangeDetection()],
});go deeper
Recall that zone.js patches timers, promises and events, and that Angular runs change detection after those callbacks finish in the Angular zone.
Walk through the chain: patched callback, NgZone leaving the zone, onMicrotaskEmpty, ApplicationRef.tick(), and state that zone.js knows when, not what.
Explain why whole-tree checks after every event cost performance, how v21 zoneless and v22 OnPush defaults change the countdown case, and why signals work in both modes.
Frame zone.js as an inference-based trigger and weigh keeping it in a legacy app against the ecosystem cost of monkey-patching and down-levelled async code.
## The problem zone.js solves Angular templates are bound to plain component fields in older code: `{{ remaining }}` reads `this.remaining`. JavaScript has no built-in way to observe an assignment to a plain field, so something must decide **when** to re-check the bindings. Before signals, Angular's answer was: re-check after any asynchronous callback finishes, because that is when state may have changed. `zone.js` is the library that makes "after any asynchronous callback" observable. ## What zone.js patches When the `zone.js` polyfill loads, it replaces browser APIs with wrappers that remember which **zone** scheduled the work and run the callback inside it. Its browser and common patches include: - **Timers:** `setTimeout`, `setInterval` and their clear functions - **Promises:** the global `Promise` is replaced by a zone-aware implementation - **Events:** `addEventListener` on `EventTarget` - **Network:** `XMLHttpRequest` and `fetch` - **Frames and observers:** `requestAnimationFrame`, `MutationObserver`, `ResizeObserver`, `IntersectionObserver` - **Others:** `queueMicrotask`, `FileReader`, `customElements`, geolocation, and more Native `async`/`await` cannot be patched effectively; the Angular docs note that it must be down-levelled to work with zone.js. ## From callback to tick ```ts import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ selector: 'app-countdown', template: `<p>{{ remaining }} s</p>`, // Eager was the implicit default before v22; new components are OnPush. changeDetection: ChangeDetectionStrategy.Eager, }) export class Countdown { remaining = 10; constructor() { const step = () => { this.remaining--; if (this.remaining > 0) setTimeout(step, 1000); }; setTimeout(step, 1000); } } ``` With zone-based change detection and an `Eager` component, each timer callback follows the same path: 1. The patched `setTimeout` fires the callback **inside the Angular zone**. 2. `NgZone` marks itself unstable on entry and emits `onUnstable` if it was stable. 3. The callback decrements `remaining`. 4. On exit, if no microtasks are pending, `NgZone` emits **`onMicrotaskEmpty`**. 5. Angular's zone scheduler, subscribed to that event, calls **`ApplicationRef.tick()`**. 6. `tick()` walks the view tree from the root, refreshing `Eager` views and dirty `OnPush` views, and updates any binding whose value changed. 7. `NgZone` then emits `onStable`. ## What the zone does not know | zone.js knows | zone.js does not know | |---|---| | a patched callback ran in the Angular zone | whether it changed any state | | the microtask queue is now empty | which components depend on the change | | | anything about work started outside the Angular zone | That gap is why zone-based apps start a tree-wide check after every patched event, including a `mousemove` that changed nothing. `OnPush` limits **which** views a tick refreshes; it does not reduce **how often** ticks happen. ## The version picture - Up to v20, zone-based change detection was the default for applications. - Since **v21**, **zoneless** is the default. An app gets the zone scheduler only when it calls `provideZoneChangeDetection()` (in `bootstrapApplication` providers, or `applicationProviders` for `bootstrapModule`) and loads `zone.js` as a polyfill. The v21 update added the provider to existing apps automatically. - Since **v22**, components without a `changeDetection` value are `OnPush`, so a tick no longer refreshes a new component whose plain field changed in a timer unless something marks it dirty. For the countdown today, a `signal()` updated in the timer works in both modes: the signal write marks the reading view and notifies the scheduler. ## Why the Angular zone matters A **zone** is an execution context that survives across asynchronous boundaries: a callback scheduled inside a zone runs inside that same zone later. Angular forks its own child zone at bootstrap and runs the application in it, so everything the app schedules (timers, listeners attached through templates, HTTP calls) inherits the Angular zone. Two consequences follow: - Work started **inside** the Angular zone ends with an `onMicrotaskEmpty` check and, usually, a tick. - Work started **outside** it, for example by a third-party library initialised in a parent zone, does not trigger the zone scheduler. Angular's hybrid scheduler (since v18) still schedules a tick when such code writes a signal or calls `markForCheck()`, but a plain field assignment there goes unnoticed. This is why "it works when I click, but not when the library's callback fires" is a classic zone.js bug report. ## Interview summary zone.js intercepts async APIs, NgZone reports when the Angular zone's microtasks drain, and Angular answers with `ApplicationRef.tick()`. It is a "something may have changed" signal, which is both its convenience and its cost.
- Why can zone.js not see native async/await, and what does that mean for a zone-based app?Native `await` resumes through the engine's internal promise machinery, which zone.js cannot intercept the way it replaces the global `Promise`. The Angular docs state that async/await must be down-levelled to work with zone.js, so zone-based builds transpile it to code that uses the patched `Promise`.
- Does a tick in a zone-based app refresh every component?It starts from the root and refreshes views that are checked on every pass, `ChangeDetectionStrategy.Eager` ones, plus `OnPush` views that were marked dirty. Since v22 new components default to `OnPush`, so a tick can run and still skip a component whose plain field changed in a timer.
zone.js is a doorman who logs every delivery into the building but never opens the boxes. Whenever deliveries stop for a moment, he rings the building manager, who walks the building from the top checking for changes, whether or not anything arrived.
saying these in an interview costs you the question
- zone.js detects which component field changed and updates only that binding
- Angular polls the component tree on a fixed interval
- zone.js is still loaded and active in every new Angular app by default
- onMicrotaskEmpty fires only once per task and marks exactly one component
- zone.js intercepts native async/await just like it patches Promise