In Angular's NgZone, how do onMicrotaskEmpty and onStable differ, and why is onStable not a signal that all pending async work has finished?
answer
- can fire more than once
- the last one in the turn
- which zone the subscriber runs in
- timers still pending
basics
~20 sonMicrotaskEmpty fires, possibly several times per turn, whenever the Angular zone's microtask queue drains, and drives ApplicationRef.tick(); onStable fires once after the last onMicrotaskEmpty, outside the Angular zone, and says nothing about timers or requests still pending.
solid answer
~40 s`NgZone.onMicrotaskEmpty` is emitted each time code leaves the Angular zone with no microtasks queued. Angular's zone scheduler calls `ApplicationRef.tick()` on it, and because a tick can queue new microtasks, it can fire several times in one turn. `onStable` is emitted once after the last `onMicrotaskEmpty`, when no more microtasks remain and the zone is about to give up the turn; its subscribers run **outside** the Angular zone. It only concerns microtasks: a pending `setTimeout`, `setInterval` or HTTP request does not delay it. For "the app has no pending work" Angular uses `ApplicationRef.isStable` and `whenStable()`, which in a zone-based app also wait for pending macrotasks. Code that used `onStable` to run DOM work after rendering should use `afterNextRender()` or `afterEveryRender()` instead.
code
ts · 16 linesimport { Component, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';
@Component({
selector: 'app-countdown-bar',
template: `<div #bar class="bar"></div>`,
})
export class CountdownBar {
private readonly bar = viewChild.required<ElementRef<HTMLElement>>('bar');
constructor() {
afterNextRender(() => {
const width = this.bar().nativeElement.getBoundingClientRect().width;
console.log('bar width after first render', width);
});
}
}go deeper
Know that NgZone has onMicrotaskEmpty and onStable, and that Angular runs change detection when the zone's microtasks drain.
Explain why onMicrotaskEmpty can repeat within a turn, why onStable fires once outside the zone, and that onStable ignores pending macrotasks.
Separate microtask stability from application stability, diagnose code waiting on the wrong signal, and move post-render work to afterNextRender or afterEveryRender.
Audit a codebase for NgZone event subscriptions as a zoneless-readiness risk and decide which ones become render hooks and which become pending-task-aware waits.
## Four events on NgZone `NgZone` is Angular's wrapper around the zone it forks for the application, the **Angular zone**. It exposes four `EventEmitter`s: | Event | When it fires | How often per turn | |---|---|---| | `onUnstable` | code enters the Angular zone while it was stable | once, at the start | | `onMicrotaskEmpty` | code leaves the zone and no microtasks are pending | possibly several times | | `onStable` | after the last `onMicrotaskEmpty`, when no microtasks remain | once, at the end | | `onError` | an error is thrown inside the zone | per error | It also exposes flags such as `isStable`, `hasPendingMicrotasks` and `hasPendingMacrotasks`. ## onMicrotaskEmpty drives change detection In a zone-based app (one that calls `provideZoneChangeDetection()`), Angular's zone scheduler subscribes to `onMicrotaskEmpty` and, each time it fires, runs `ApplicationRef.tick()` inside the zone. Why can it fire more than once in a turn? The tick itself can queue microtasks, for example a promise resolved during change detection or a template-driven form control updating asynchronously. When those drain, the zone is again leaving with an empty queue, so `onMicrotaskEmpty` fires again and another tick runs. The loop ends when a pass queues nothing new. ## onStable closes the turn After the final `onMicrotaskEmpty`, if the queue is still empty, `NgZone` emits `onStable` and marks itself stable. Two details matter: 1. `onStable` is emitted via `runOutsideAngular`, so its subscribers run **outside** the Angular zone. Work there does not by itself start a new zone turn. 2. It fires **once** per turn, which made it the old place for "do this after Angular has rendered". ## What onStable does not mean The name suggests "the application is idle", but `onStable` is about **microtasks** only. - A `setTimeout` scheduled for later is a macrotask; it is still pending when `onStable` fires. - An HTTP request in flight does not hold `onStable` back. - A `setInterval` fires `onStable` after every tick, forever. When code needs "no pending work at all", for example server rendering deciding when to serialize a page, or a test waiting for everything to settle, Angular uses `ApplicationRef.isStable` (an Observable of booleans) and `ApplicationRef.whenStable()` (a Promise). These are built on Angular's pending-task tracking. In a zone-based app, a pending-task entry is held while the zone reports pending macrotasks, so a long-running `setInterval` started in the Angular zone keeps the application unstable. ## Why subscribing to onStable is now legacy Older code often did this: ```ts this.ngZone.onStable.pipe(take(1)).subscribe(() => this.measure()); ``` It works only in zone-based apps, and in zoneless apps (the default since v21) `NgZone` is a no-op implementation whose events never fire. The render hooks replace it: - `afterNextRender()` runs once after the next render, - `afterEveryRender()` (formerly `afterRender`) runs after every render. Both work with or without zone.js. ## Worked timeline for one click Consider a click handler that resolves a promise and starts a 1-second countdown timer: 1. The click listener runs inside the Angular zone: `onUnstable` fires. 2. The handler resolves a promise, queuing a microtask; the handler returns. 3. The microtask runs; the queue is now empty: `onMicrotaskEmpty` fires and Angular ticks. 4. Suppose the tick resolves another promise: when it drains, `onMicrotaskEmpty` fires again and a second tick runs. 5. Nothing else is queued: `onStable` fires, outside the Angular zone. 6. One second later the timer fires, and the sequence begins again with `onUnstable`. At step 5 the timer from the click is still pending, which is exactly the case where treating `onStable` as "idle" goes wrong. ## Summary for an interview - `onMicrotaskEmpty`: "the zone's microtasks drained", the tick trigger, can repeat. - `onStable`: "the turn is over", once, outside the zone, microtasks only. - `isStable` / `whenStable()`: "no pending work", including timers and requests in zone mode.
- Why do NgZone's events never fire in a zoneless Angular app?Without `provideZoneChangeDetection()`, the injector provides a no-op `NgZone`: `run()` and `runOutsideAngular()` simply call the function, and `onMicrotaskEmpty`, `onStable` and the rest are emitters nothing ever emits on. Code that waits for them waits forever, which is why render hooks and `whenStable()` are the portable choices.
- A component starts a setInterval in its constructor inside the Angular zone. What does that do to ApplicationRef.isStable?In a zone-based app the interval is a pending macrotask, so Angular's stability tracking keeps a pending task and `isStable` never emits `true`. Anything waiting on `whenStable()` keeps waiting. `onStable` still fires after each tick, which is exactly why it is the wrong signal for idleness.
saying these in an interview costs you the question
- onStable fires only after all timers and HTTP requests have completed
- onMicrotaskEmpty fires exactly once per browser task
- onStable subscribers run inside the Angular zone and trigger another tick
- NgZone events work the same in zoneless apps
- Subscribing to onStable is still the recommended way to run post-render code