skip to content

When moving an Angular app to zoneless, why must code that waits on NgZone.onStable or onMicrotaskEmpty change, and what replaces it?

level: seniorimportance: should knowfreq 38%

answer

  1. these observables go silent
  2. isStable always true
  3. render hooks instead
  4. one render versus every render

basics

~20 s

In a zoneless Angular app, NgZone.onStable, onMicrotaskEmpty and onUnstable never emit and NgZone.isStable is always true. Code waiting for a check to finish should use afterNextRender (one render) or afterEveryRender (each render), or a direct DOM API.

solid answer

~40 s

`NgZone.onStable`, `onMicrotaskEmpty` and `onUnstable` are driven by zone.js task tracking. A zoneless app injects a no-op `NgZone`, so these observables **never emit** and `NgZone.isStable` is **always `true`**; a subscription that scrolled a row into view or measured an element after the next check silently never runs, and an `isStable` guard is always passed. The documented replacements: `afterNextRender()` when you need to run once after the next render, `afterEveryRender()` when a condition may span several checks, and a direct DOM API such as `MutationObserver` or `ResizeObserver` when you are really waiting for a DOM state, not for Angular. Both render hooks accept phases (`earlyRead`, `write`, `mixedReadWrite`, `read`) and an `injector` option for use outside an injection context. `ApplicationRef.isStable` and `whenStable()` still work zoneless, because they are driven by pending tasks, not by the zone.

code

ts · 29 lines
ts
import {Component, ElementRef, Injector, afterNextRender, inject, input, viewChild} from '@angular/core';

@Component({
  selector: 'app-order-list',
  template: `
    <ul #list>
      @for (id of orderIds(); track id) {
        <li [attr.data-id]="id">{{ id }}</li>
      }
    </ul>
  `,
})
export class OrderList {
  readonly orderIds = input.required<string[]>();
  private readonly list = viewChild.required<ElementRef<HTMLUListElement>>('list');
  private readonly injector = inject(Injector);

  select(id: string) {
    // Replaces: this.zone.onStable.pipe(take(1)).subscribe(...)
    afterNextRender(
      {
        read: () => {
          this.list().nativeElement.querySelector(`[data-id="${id}"]`)?.scrollIntoView();
        },
      },
      {injector: this.injector},
    );
  }
}

go deeper

for a junior

Know that NgZone's onStable-style observables stop working without zone.js and that afterNextRender is the usual replacement.

for a middle

Explain why these observables go silent, the difference between afterNextRender and afterEveryRender, and the phase and injector options.

for a senior

Audit a codebase for silent onStable dependencies, choose the replacement by intent, and keep NgZone.run/runOutsideAngular where they protect zone-based consumers.

for a principal

For shared libraries used by both zone-based and zoneless apps, weigh keeping compatibility against simplifying: render hooks and pending tasks work in both worlds.

## What these observables were `NgZone` is Angular's wrapper around zone.js. It exposes observables that report the zone's task state: - **`onUnstable`**: the zone started running code. - **`onMicrotaskEmpty`**: the microtask queue drained; in zone-based apps Angular runs its change detection on this event. - **`onStable`**: the last `onMicrotaskEmpty` produced no more work, so the zone is idle. - **`isStable`**: a boolean snapshot of the same idea. Because change detection ran on `onMicrotaskEmpty`, subscribing to `onStable` (often with `take(1)`) became a common way to say "after Angular has updated the DOM". Libraries used it to focus inputs, measure elements or position overlays. ## What happens without zone.js Zoneless bootstrap provides a **no-op `NgZone`**. Its observables are plain event emitters that nothing ever triggers, and `isStable` is a constant `true`. The zoneless guide is explicit: applications and libraries need to remove uses of `onMicrotaskEmpty`, `onUnstable` and `onStable`, which never emit, and must not use `isStable` as a condition. The failure is silent. No error is thrown; the callback simply never runs. The symptoms are a list that no longer scrolls to the selected row, an autofocus that stopped working, or a tooltip stuck at the top-left corner. ## The replacements | Old code intent | Zoneless replacement | |---|---| | Run once after the next change detection | `afterNextRender(cb)` | | Re-run after each render while some condition holds | `afterEveryRender(cb)` | | Wait for a particular DOM state to appear | `MutationObserver`, `ResizeObserver`, `IntersectionObserver` | | Wait for the whole app to be idle (e.g. pending HTTP) | `ApplicationRef.whenStable()` / `isStable` | `afterNextRender` and `afterEveryRender` (the v20 name for the older `afterRender`) are Angular's **render hooks**. They run in the browser only, never during server rendering, and they accept either a single callback or an object of **phases** (`earlyRead`, `write`, `mixedReadWrite`, `read`) so that DOM reads and writes are not interleaved and forced layouts are avoided. Outside an injection context, pass the `injector` option. `ApplicationRef.isStable` looks similar to `NgZone.isStable` but is a different mechanism: it is derived from Angular's **pending tasks**, which the router and `HttpClient` register, so it still reports correctly in a zoneless app. ## A worked migration ```ts // Before: relies on zone.js this.zone.onStable.pipe(take(1)).subscribe(() => this.scrollToSelected()); // After: works with or without zone.js afterNextRender(() => this.scrollToSelected(), {injector: this.injector}); ``` Steps for a codebase: 1. Search for `onStable`, `onMicrotaskEmpty`, `onUnstable` and `isStable` on `NgZone`. 2. Classify each use by intent using the table above. 3. Replace with the render hook or DOM API that matches that intent, not the one with the most similar timing. 4. Leave `NgZone.run()` and `runOutsideAngular()` alone; the guide says they are compatible with zoneless and removing them can hurt libraries used in zone-based apps. ## Library code that must work in both worlds A shared component library may be consumed by applications that still run zone.js and by applications that are zoneless. Render hooks are the safe common ground: `afterNextRender` and `afterEveryRender` behave the same way under both schedulers, because they are tied to Angular's rendering rather than to zone task tracking. The same holds for `ApplicationRef.whenStable()`, which both schedulers derive from pending tasks. By contrast, a library that keeps an `onStable` subscription works for its zone-based consumers and silently fails for zoneless ones, which is the hardest kind of bug report to reproduce. When auditing a dependency before a zoneless migration, searching its published code for `onStable`, `onMicrotaskEmpty` and `isStable` is a fast first check. ## Why intent matters more than timing The guide notes that many uses of these observables existed only because they were familiar and happened to have similar timing to what was needed. `onStable` fired after *any* zone turn, not after the one change you cared about. `afterNextRender` fires after the next render Angular performs, which is the precise statement most of that code wanted. When the real need is "after this element has a size", a `ResizeObserver` expresses it directly and does not depend on Angular at all.

  • In Angular, when would you pick afterEveryRender over afterNextRender?
    Use `afterNextRender` for one-off work after the next render, such as initialising a widget or focusing an element. Use `afterEveryRender` when the work must re-run after each render, for example keeping a manually positioned element aligned while content changes; it runs after every change detection until its `AfterRenderRef` is destroyed or its injector is destroyed.
  • Is ApplicationRef.isStable safe to use in a zoneless Angular app?
    Yes. Unlike `NgZone.isStable`, `ApplicationRef.isStable` is derived from Angular's pending tasks, which the router, `HttpClient` and `PendingTasks` register. `whenStable()` resolves when no pending task remains, which is what server rendering and tests wait for in a zoneless app.

saying these in an interview costs you the question

  • NgZone.onStable still emits in zoneless apps, just less often.
  • NgZone.isStable is always false without zone.js.
  • Replace onStable with setTimeout(0) and it behaves the same.
  • NgZone.run() calls must all be deleted before going zoneless.
  • afterNextRender also runs during server-side rendering.