skip to content

Zone Pollution Fixes

Third-party libraries, timers and scroll listeners running inside the zone trigger needless app-wide checks; runOutsideAngular() fixes it. Interviewers ask how to spot and remove it.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In a zone-based Angular app, how do NgZone.runOutsideAngular() and NgZone.run() work together, and what breaks if you forget to re-enter?

level: middleimportance: must knowfreq 48%

answer

  1. the zone is inherited by callbacks
  2. leave for the noisy work
  3. come back for the one update
  4. isInAngularZone tells you where

basics

~20 s

Code started in runOutsideAngular(), and every callback it schedules, runs outside the Angular zone and triggers no checks. run() re-enters for the update that matters; forget it and a plain-field update, even in a parent's output handler, never renders.

solid answer

~50 s

A zone is inherited: timers, listeners and promises created inside `runOutsideAngular()` keep running outside the Angular zone for their whole life. That is the point, since the noisy work stops triggering checks, but it also means a callback that must update the UI is outside too. `NgZone.run(fn)` re-enters: zone.js treats `fn` as a task inside the Angular zone and Angular checks when it finishes. Forget it, and a callback that assigns a plain field, or emits an `output()` whose parent handler assigns one, changes state with no check, so the screen stays stale until something unrelated triggers one. `NgZone.isInAngularZone()` tells you where code is running. Since v18, zone-based apps also schedule a check when a template-read signal is set or `markForCheck()` is called outside the zone, so a signal write is an alternative to `run()`. Keep `run()` blocks small.

code

ts · 26 lines
ts
import {Component, ElementRef, NgZone, afterNextRender, inject, output, viewChild} from '@angular/core';

interface EditorHandle { on(event: 'save', cb: (doc: string) => void): void; }
declare function createEditor(host: HTMLElement): EditorHandle;

@Component({
  selector: 'app-note-editor',
  template: `<div #host></div>`,
})
export class NoteEditor {
  private readonly zone = inject(NgZone);
  private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
  readonly saved = output<string>();

  constructor() {
    afterNextRender(() => {
      this.zone.runOutsideAngular(() => {
        const editor = createEditor(this.host().nativeElement);
        editor.on('save', (doc) => {
          console.log('in Angular zone?', NgZone.isInAngularZone()); // false
          this.zone.run(() => this.saved.emit(doc));
        });
      });
    });
  }
}

go deeper

for a junior

Know that runOutsideAngular() stops Angular reacting to some work and run() brings code back so the UI updates.

for a middle

Explain zone inheritance, the leave-then-re-enter pattern, and why a forgotten run() leaves plain-field state stale.

for a senior

Keep re-entry narrow, trace outputs and HTTP calls that inherit the outside zone, and prefer signal writes where they are clearer.

for a principal

Set library-wrapping conventions that work in both zone-based and zoneless apps, so wrappers do not need rewriting during a migration.

## Zones are inherited In a zone-based Angular app, zone.js propagates the **current zone** to everything scheduled from it. A `setTimeout` created inside the Angular zone calls back inside the Angular zone; an event listener added inside it fires inside it. `NgZone` exposes two methods that move code between zones: - **`runOutsideAngular(fn)`** runs `fn` in the Angular zone's parent. Everything `fn` schedules (timers, listeners, promise continuations, library-internal tasks) is also outside, for as long as it lives. - **`run(fn)`** runs `fn` inside the Angular zone. When it finishes, zone.js reports a completed task and Angular runs change detection. The static method **`NgZone.isInAngularZone()`** returns whether the current code is inside the Angular zone, which is the quickest way to check an assumption while debugging. ## The pattern The Angular performance guide's recipe for third-party libraries is: 1. Initialise the library inside `runOutsideAngular()`, so its internal timers and listeners stop triggering checks. 2. Subscribe to the few library events the application cares about. 3. Inside those handlers, wrap the state update in `run()` so Angular checks once. ```ts this.zone.runOutsideAngular(() => { const editor = createEditor(this.host().nativeElement); editor.on('save', (doc) => { this.zone.run(() => this.saved.emit(doc)); // re-enter for the one event that matters }); }); ``` ## What breaks if you forget `run()` The library handler above runs outside the Angular zone because it was registered there. Without `run()`: | Handler does | Result in a zone-based app | |---|---| | Assigns a plain field read by a template | No check is triggered; the screen stays stale | | Emits an `output()` whose parent handler assigns a plain field | The parent handler also runs outside the zone; the parent stays stale | | Calls a service that later makes an `HttpClient` request | The request runs outside the zone too; a plain-field update in its response triggers no check | | Sets a signal read by a template | A check is scheduled anyway (hybrid scheduling, since v18) | | Calls `ChangeDetectorRef.markForCheck()` | A check is scheduled anyway (since v18) | The symptom is typical: the value appears only after the user clicks something else, because that click triggers a check from inside the zone. It looks intermittent, but it is deterministic: nothing notified Angular. ## Signals as the alternative Since v18, a zone-based app's scheduler also listens for Angular's own notifications that come from **outside** the zone: setting a signal that a template reads, or calling `markForCheck()`. So a handler that does `this.selection.set(doc)` renders without `run()`. That is often cleaner: - it expresses what changed rather than where code runs; - it keeps working unchanged if the app later goes zoneless, where `run()` does nothing; - the check it schedules is targeted: views that read the signal are refreshed, rather than every `Eager` view from the root. `run()` is still the right tool when the code inside must run in the Angular zone for other reasons, for example to let zone-based libraries or `HttpClient` calls started from it be tracked. ## Common mistakes 1. **Re-entering too early.** Calling `run()` around the library's initialisation instead of around one callback puts everything back inside the zone. 2. **Assuming outputs are safe.** An `output()` emitted outside the zone runs the parent's handler outside the zone as well; the parent's plain fields stay stale. 3. **Forgetting inherited async work.** A promise chain or an RxJS subscription started from an outside callback continues outside, so a later `.then()` that updates state also needs re-entry or a signal. 4. **Debugging by guesswork.** Logging `NgZone.isInAngularZone()` at the suspicious line answers the question in seconds. ## Keep the re-entry narrow - Wrap only the state update, not the whole handler; heavy work inside `run()` makes the zone treat it all as Angular work. - Do not wrap high-frequency events such as `mousemove` in `run()` unconditionally, or you recreate the pollution you removed. Re-enter only when the value actually changes, or throttle first. - Leave `runOutsideAngular()` and `run()` in place when moving to zoneless: they become plain function calls and the zoneless guide says removing them can regress zone-based consumers.

  • In an Angular zone-based app, why can an output emitted from a runOutsideAngular() callback leave the parent stale?
    Emitting an output calls the parent's handler synchronously, in the same zone as the emitter. If the emitter runs outside the Angular zone, so does the parent's handler, and a plain-field assignment there triggers no check. Wrapping the emit in `NgZone.run()` puts the handler inside the zone; making the parent's state a signal also works since v18.
  • How do you verify at runtime whether Angular code is running inside the Angular zone?
    Call the static `NgZone.isInAngularZone()`; it returns `true` inside the Angular zone and `false` outside it. Logging it inside a third-party callback is the quickest way to confirm that the handler inherited the outside zone from where the library was initialised.

saying these in an interview costs you the question

  • Only the function passed to runOutsideAngular() runs outside the zone.
  • Callbacks registered outside the zone return to it automatically.
  • Output handlers always run inside the Angular zone.
  • Wrap every high-frequency event handler in NgZone.run() to be safe.
  • In a zone-based app, only NgZone.run() can trigger a check from outside the zone.
open as a page

In a zone-based Angular app, why does a setInterval that changes nothing on screen still make Angular run change detection on every tick?

level: juniorimportance: should knowfreq 40%

basics

~20 s

zone.js patches setInterval, so each callback that runs inside the Angular zone looks like work that might have changed state, and Angular checks the whole view tree afterwards. Starting the timer with NgZone.runOutsideAngular() stops those needless checks.

open as a page

A zone-based Angular dashboard embeds a chart library that tracks mousemove and scroll for tooltips, and the whole page lags; how do you confirm zone pollution and fix it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Angular DevTools' profiler shows a run of change detection cycles whose source is mousemove, scroll or requestAnimationFrame while the pointer moves. Initialise the chart inside NgZone.runOutsideAngular() and re-enter with run(), or set a signal, only for events that change Angular-rendered state.

open as a page

Does moving an Angular application to zoneless change detection eliminate zone pollution, and which needless checks can still remain?

level: seniorimportance: nice to knowfreq 25%

basics

~20 s

Mostly yes: without zone.js, timers, animation frames and third-party listeners trigger no checks, and runOutsideAngular() becomes a plain call. What remains are high-frequency template or host listeners, which still notify on each event, and code that writes signals on every event.

open as a page