Skip to main content

What is ngzone in Angular?

NgZone is Angular's wrapper around Zone.js that patches browser async APIs to automatically trigger change detection when tasks complete inside the Angular zone.

Theory

TL;DR

  • NgZone acts like a motion sensor: any async activity inside it (setTimeout, fetch) tells Angular to check the UI for updates.
  • Inside the zone, Angular runs change detection automatically. Outside, it ignores async results entirely.
  • Use runOutsideAngular() for heavy loops that don't touch the UI. Use run() to bring third-party callbacks back in.
  • Zone.js patches 40+ browser APIs at bootstrap so Angular can intercept task completion.
  • Angular 17+ supports zoneless mode with signals as an alternative path.

Quick example

typescript
import { Component, NgZone, OnInit } from '@angular/core'; @Component({ selector: 'app-demo', template: `<p>{{count}}</p>` }) export class DemoComponent implements OnInit { count = 0; constructor(private ngZone: NgZone) {} ngOnInit() { // Inside zone: Angular detects the change and updates the template setTimeout(() => this.count++, 1000); // Outside zone: count increments in memory, template stays at 0 this.ngZone.runOutsideAngular(() => { setTimeout(() => this.count++, 1000); }); } }

The first setTimeout is intercepted by Zone.js, so Angular knows to run change detection when it finishes. The second runs in a forked zone Angular does not watch.

How it works internally

Zone.js replaces native browser APIs at bootstrap. When your code calls window.setTimeout, it actually calls Zone.js's version, which forks a child zone, runs the callback, and notifies Angular's NgZoneImpl on completion. Angular then schedules ApplicationRef.tick() via queueMicrotask, which walks the component tree and checks for updates.

In dev mode, Angular checks the full component tree. With OnPush components, it only checks marked subtrees. Either way, the trigger is the same: a task completing inside the zone.

Third-party WebSocket callbacks, native requestAnimationFrame, and many library events run outside the Angular zone by default. Zone.js does not patch every async path. That is where most NgZone bugs originate.

When to use

  • Heavy animation loops and setInterval counters that update UI only at the end: runOutsideAngular() stops Angular from running change detection 60 times per second.
  • WebSocket or third-party library callbacks that need to update the template: wrap in ngZone.run() to bring them into the zone.
  • Server-side rendering with Angular Universal: check NgZone.isInAngularZone() before DOM mutations to avoid hydration mismatches.
  • Any async that updates @Input or @Output bindings: stay inside the zone (the default behavior).

Common mistakes

Mistake 1: Assuming WebSocket callbacks are inside the zone

typescript
// Wrong: template never updates const socket = new WebSocket('ws://api.example.com/data'); socket.onmessage = (event) => { this.data = event.data; // Zone.js does not fully patch WebSocket }; // Correct socket.onmessage = (event) => { this.ngZone.run(() => { this.data = event.data; }); };

The first time this caught me off guard was a real-time chart where WebSocket data arrived but nothing updated. The array was growing in memory. The change detector was never told to look.

Mistake 2: Using runOutsideAngular() on code that updates the UI

typescript
// Wrong: counter freezes in the template this.ngZone.runOutsideAngular(() => { setInterval(() => this.counter++, 1000); }); // Correct: re-enter zone on each update this.ngZone.runOutsideAngular(() => { setInterval(() => { this.counter++; this.ngZone.run(() => {}); // Force a tick }, 1000); });

Mistake 3: Not setting up intervals outside the zone from the start

typescript
// Wrong: zone tracks the pending task the whole time ngOnInit() { this.timer = setInterval(() => this.frameCount++, 16); // Inside zone } ngOnDestroy() { clearInterval(this.timer); // Zone was watching it the entire time } // Correct: create outside zone, it never gets tracked ngOnInit() { this.ngZone.runOutsideAngular(() => { this.timer = setInterval(() => this.frameCount++, 16); }); } ngOnDestroy() { clearInterval(this.timer); }

Mistake 4: Nested run() calls in Angular 16+

Calling ngZone.run() from code already inside the zone nests zones and triggers double tick() calls. Check first:

typescript
const update = () => { this.value = newValue; }; if (this.ngZone.isInAngularZone()) { update(); } else { this.ngZone.run(update); }

Real-world usage

  • NG Bootstrap: NgbModal uses runOutsideAngular() for animation frames to hit 60fps.
  • PrimeNG DataTable: virtual scroll handlers run outside the zone to avoid change detection on every scroll event.
  • Angular Material CDK: overlay events go through NgZone.run() when overlays open or close.
  • RxJS in services: tap(() => this.ngZone.run(() => this.update())) when an observable originates outside the zone.
  • Angular Universal SSR: isBrowser ? ngZone.run(...) : directDomCall(...) guards against zone mismatches.

ChangeDetectorRef.detectChanges() is the component-local alternative. It is faster because it does not walk the whole tree. Use NgZone when the trigger comes from a service or a cross-component async source.

Follow-up questions

Q: How does Zone.js patch setTimeout and addEventListener?
A: Zone.js replaces window.setTimeout with a wrapper that calls currentZone.runGuarded(fn). For addEventListener, it wraps the handler in a proxy that forks a child zone on dispatch. Both notify NgZoneImpl when the task completes.

Q: What is the performance cost of staying inside the Angular zone?
A: Each ApplicationRef.tick() call costs 10-50ms on large apps because it walks the component tree. Running hot loops outside the zone avoids those ticks entirely, but any UI update then requires a manual run().

Q: In Angular 17+ with signals, does NgZone still matter?
A: Signals use a scheduler instead of zone-based patching, so zoneless apps can skip Zone.js completely. If you have legacy zone-based code or third-party libraries that expect zones, NgZone stays relevant as a bridge between the two models.

Q: In an OnPush component with async pipe, does NgZone affect change detection?
A: The async pipe marks the component dirty via ChangeDetectorRef.markForCheck(), not via zone. If an observable comes from outside the zone, the async pipe alone is not enough. You still need ngZone.run() to schedule the tick that reads the dirty flag.

Q: (Senior) How do you debug a frozen UI after upgrading a third-party library?
A: In browser devtools, run ng.probe($0).injector.get(NgZone).isInAngularZone() while the app processes data. If it returns false, the library callback runs outside the zone. Wrap it in ngZone.run() or switch to a version with native Angular integration.

Examples

WebSocket dashboard

typescript
import { Component, NgZone, OnDestroy } from '@angular/core'; @Component({ selector: 'app-dashboard', template: `<ul><li *ngFor="let d of data">{{d}}</li></ul>` }) export class DashboardComponent implements OnDestroy { data: number[] = []; private socket: WebSocket; constructor(private ngZone: NgZone) { this.socket = new WebSocket('ws://api.example.com/metrics'); // WebSocket onmessage runs outside Angular zone by default this.socket.onmessage = (event) => { this.ngZone.run(() => { // Now inside zone: Angular sees the change and re-renders the list this.data.push(+event.data); }); }; } ngOnDestroy() { this.socket.close(); } } // Result: new values appear in the template as they arrive

Without ngZone.run(), the array grows in memory but the template never re-renders. This is the most common NgZone bug in production dashboards.

Infinite scroll with large lists

typescript
import { Component, NgZone } from '@angular/core'; @Component({ selector: 'app-list', template: ` <div *ngFor="let item of items">{{item}}</div> <p *ngIf="loading">Loading...</p> ` }) export class InfiniteListComponent { items: string[] = []; loading = false; constructor(private ngZone: NgZone) { window.addEventListener('scroll', () => { if (window.innerHeight + window.scrollY >= document.body.offsetHeight) { this.loadMore(); } }); } private async loadMore() { this.loading = true; const newItems = await fetchMore(); // Promise runs outside zone // Re-enter zone only when data is ready, not on every scroll pixel this.ngZone.run(() => { this.items.push(...newItems); this.loading = false; }); } } // Result: smooth scroll on 10k+ item lists, CD fires only when data arrives

Change detection runs once per load batch. Not on every scroll event.

Progress bar with heavy computation

typescript
import { Component, NgZone } from '@angular/core'; @Component({ selector: 'app-progress', template: `<div [style.width.%]="progress"></div>` }) export class ProgressComponent { progress = 0; constructor(private ngZone: NgZone) {} startHeavyTask() { // 1000 animation frames outside zone, one final render at the end this.ngZone.runOutsideAngular(() => { let i = 0; const tick = () => { i++; this.progress = i / 10; // Updates internal value only if (i < 1000) { requestAnimationFrame(tick); } else { // Back inside zone for the final render this.ngZone.run(() => { this.progress = 100; }); } }; requestAnimationFrame(tick); }); } } // Result: 0 change detection cycles during 1000 frames, 1 final render

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?