Zone.js Change Detection Memory Overhead

If a component keeps growing the heap after it is destroyed, the cause is often not your own code but how Angular RxJS subscription leaks interact with Zone.js inside the wider area of framework-specific memory optimization: Zone.js patches setInterval, addEventListener, and XHR globally, and a patched callback can outlive the component that scheduled it.

Symptom Root Cause Immediate Action
Heap grows per route change Zone keeps patched task alive Cancel task in ngOnDestroy
CD runs after component gone Macrotask still scheduled Wrap call in runOutsideAngular
Idle CPU ticks in Performance tab Zone fires empty CD cycles Move timers outside zone
Detached tree in heap snapshot Closure over this retained Null out refs, clear handles

Root Cause

Zone.js works by monkey-patching every async browser API — timers, DOM events, promises, XHR, MutationObserver — so Angular can know when “something happened” and re-run change detection. Each patch wraps your callback in a ZoneTask object that stores the original function, its arguments, and a reference to the zone it was scheduled in. That wrapper closure captures whatever this context your callback closed over, which usually includes the component instance and, transitively, its template bindings, injected services, and any DOM nodes it touches — the same closure-retention pattern covered in closure memory leaks.

Angular’s own component destruction does not know about tasks that Zone.js is still tracking. If a component calls setInterval or registers a native addEventListener inside the Angular zone and never cancels it, the router can destroy the component, but the ZoneTask keeps its closure alive in the macrotask queue. Every tick, that task fires, and because it runs inside NgZone, it also triggers a full change detection pass across the whole application — CPU overhead on top of the memory that is already stuck. Profiling this in the Performance panel usually shows a sawtooth of NgZone.onMicrotaskEmpty calls long after the route has changed.

The diagram below traces the lifecycle: a component schedules a timer inside the zone, gets destroyed, but the patched task keeps firing and keeps the change-detection loop — and the component’s retained closure — alive in the heap.

Zone.js task retention after component destruction A component schedules setInterval inside NgZone. Zone.js wraps the callback in a task that captures the component's closure. The component is destroyed via ngOnDestroy, but the interval is never cleared, so the ZoneTask keeps firing, triggering change detection and keeping the detached component tree resident in the heap. Component created setInterval() inside NgZone Zone.js patches the timer call ZoneTask wraps closure Component destroyed ngOnDestroy runs Interval NOT cleared Task fires again closure over old component NgZone triggers onMicrotaskEmpty → full CD pass Detached component tree stays in heap closure, DOM refs, services all retained

Step-by-Step Fix

  1. Reproduce under a Performance recording. Open DevTools → Performance → record, navigate into the route that schedules a timer or listener, then navigate away. Stop the recording. Expected Output: the flame chart still shows recurring Timer Fired or NgZone.onMicrotaskEmpty entries after the navigation event, minutes after the component should be gone.

  2. Confirm retention with heap snapshots. Go to DevTools → Memory → Heap Snapshot → Comparison view. Take snapshot A on the leaking route, navigate away, force a GC (the trash-can icon), then take snapshot B. Filter the class list by your component’s name. Verification checkpoint: the component constructor still shows a positive “# New” count in snapshot B — it should be zero.

  3. List active zone tasks from the console. Run Zone.current in the DevTools console and expand _zoneDelegate to inspect scheduled macrotasks, or use ng.getComponent($0) on a detached DOM node from the snapshot’s retainer tree to identify the owning instance. Expected Output: a ZoneTask entry whose data.args or callback closes over the destroyed component instance.

  4. Move the task outside the zone and cancel it explicitly. Inject NgZone, call the async API inside runOutsideAngular, keep the handle, and clear it in ngOnDestroy (see the code reference below, and cross-check with unsubscribing observables to prevent Angular leaks if the same component also holds RxJS subscriptions). Verification checkpoint: re-run step 2 — the “# New” count for the component in snapshot B is now 0.

  5. Reduce the patched surface with OnPush or zoneless mode. Set changeDetection: ChangeDetectionStrategy.OnPush on hot components, or opt into Angular’s zoneless change detection where practical. Expected Output: the Performance recording from step 1 shows far fewer onMicrotaskEmpty entries per second — check the count in the summary pane before and after.

Command & Code Reference

A component that leaks because its interval is patched by Zone.js and never cancelled on teardown:

// BAD: interval runs inside NgZone and is
// never cleared, so ZoneTask keeps firing
export class PollerComponent implements OnDestroy {
  count = 0;

  constructor() {
    // setInterval is patched by Zone.js here;
    // the returned handle is never stored
    setInterval(() => {
      // this closure captures `this` — the
      // whole component instance stays alive
      this.count++;
    }, 1000);
  }

  ngOnDestroy(): void {
    // no cleanup — the timer keeps running
    // and keeps triggering change detection
  }
}

The fix: run the timer outside the zone and cancel it explicitly so the closure and component can be collected:

// GOOD: task runs outside NgZone and is
// cancelled on destroy, releasing the closure
export class PollerComponent implements OnDestroy {
  count = 0;
  private handle?: ReturnType<typeof setInterval>;

  constructor(private zone: NgZone) {
    // runOutsideAngular skips the Zone.js patch
    // path, so no change detection per tick
    this.zone.runOutsideAngular(() => {
      this.handle = setInterval(() => {
        // re-enter the zone only when a UI
        // update is actually required
        this.zone.run(() => this.count++);
      }, 1000);
    });
  }

  ngOnDestroy(): void {
    // clearInterval releases the ZoneTask and
    // the closure it held over this component
    clearInterval(this.handle);
  }
}

Inspecting pending zone tasks and switching a module to zoneless mode for comparison:

// Console: list macrotasks tracked by the
// current zone (run inside DevTools console)
Zone.current._zoneDelegate
  ._taskCounts; // { macroTask: N, ... }

// bootstrap.ts: opt into zoneless change
// detection to remove the patch layer
bootstrapApplication(AppComponent, {
  providers: [
    // Angular 18+: no Zone.js patching,
    // relies on signals for CD scheduling
    provideExperimentalZonelessChangeDetection(),
  ],
});

Verification & Regression Prevention

Track two numbers per route navigation in a synthetic test: the zone macrotask count (Zone.current._zoneDelegate._taskCounts.macroTask) should return to its pre-navigation baseline within 500 ms, and the retained heap size for the component’s constructor in a snapshot comparison should be 0 bytes after forced GC. Wire this into CI with a Playwright script that opens the route ten times, forces GC via the CDP HeapProfiler.collectGarbage command, and asserts the JSHeapUsedSize delta stays under a fixed threshold (for example, 500 KB per cycle) using performance.memory or the CDP Runtime.getHeapUsage call. Add an ESLint rule such as rxjs-angular/prefer-takeuntil alongside a custom rule that flags any setInterval, setTimeout, or addEventListener call inside a component class body that lacks a matching clearInterval, clearTimeout, or removeEventListener in ngOnDestroy — catching the pattern before it reaches a profiler at all. Re-check the Chrome DevTools Memory tab comparison view after any dependency upgrade, since Zone.js patch behaviour has changed across major Angular releases.

Frequently Asked Questions

Does removing Zone.js remove memory overhead entirely?

It removes the monkey-patch layer and its per-task bookkeeping, but it does not remove leaks caused by application code that forgets to cancel timers, listeners, or subscriptions — those still need explicit cleanup with or without zones.

Why does runOutsideAngular reduce memory pressure?

Tasks scheduled outside the Angular zone are not wrapped in Zone.js’s task-tracking objects, so they do not trigger change detection on every tick and do not keep an extra zone-context closure alive for the lifetime of the interval or listener.

Is zoneless change detection safe to adopt today?

Angular 18 and later ship zoneless change detection as a production-track API behind provideExperimentalZonelessChangeDetection, and it is stable enough for incremental adoption if your component tree already uses OnPush and signals consistently.