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?
answer
- bars in the DevTools profiler
- source: mousemove, scroll, rAF
- initialise the library outside
- re-enter only for real updates
basics
~20 sAngular 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.
solid answer
~50 sFirst confirm it. Record with the Angular DevTools profiler while moving the pointer over the chart and scrolling: zone pollution shows up as a dense series of change detection bars whose source is `mousemove`, `scroll`, `requestAnimationFrame` or a timer. If your own templates bind few such events, the library is the source; logging `NgZone.isInAngularZone()` in one of its callbacks confirms it runs inside the zone. The cause is that the chart was created inside the Angular zone, so every listener and animation frame it registered inherits the zone and each one triggers an app-wide check. The fix is to create the chart inside `NgZone.runOutsideAngular()`, typically in `afterNextRender`, so its internal tasks stop notifying Angular. For the few events the app needs, such as a clicked data point shown in a side panel, re-enter with `NgZone.run()` or set a signal. Then profile again: moving the pointer should record no cycles.
code
ts · 33 linesimport {Component, DestroyRef, ElementRef, NgZone, afterNextRender, inject, signal, viewChild} from '@angular/core';
interface ChartHandle {
onPointClick(cb: (p: {label: string; value: number}) => void): void;
destroy(): void;
}
declare function createSalesChart(host: HTMLElement): ChartHandle;
@Component({
selector: 'app-sales-chart',
template: `
<div #host class="chart"></div>
@if (selected(); as p) {
<aside>{{ p.label }}: {{ p.value }}</aside>
}
`,
})
export class SalesChart {
private readonly zone = inject(NgZone);
private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
readonly selected = signal<{label: string; value: number} | null>(null);
constructor() {
const destroyRef = inject(DestroyRef);
afterNextRender(() => {
// Tooltips, crosshair frames and scroll listeners stay outside the zone.
const chart = this.zone.runOutsideAngular(() => createSalesChart(this.host().nativeElement));
// A discrete event that changes Angular-rendered state re-enters once.
chart.onPointClick((p) => this.zone.run(() => this.selected.set(p)));
destroyRef.onDestroy(() => chart.destroy());
});
}
}go deeper
Know that a third-party library running inside the Angular zone can trigger far more change detection than the app needs.
Explain zone inheritance for library listeners and apply the runOutsideAngular-then-run pattern.
Confirm pollution with the DevTools profiler before changing code, fix it at initialisation, keep re-entry narrow and verify the bars disappear.
Standardise how third-party widgets are wrapped so pollution fixes are not rediscovered per team, and weigh that against going zoneless.
## The symptom A dashboard renders a sales chart with a third-party charting library. The library shows tooltips on `mousemove`, redraws its crosshair with `requestAnimationFrame` and listens to `scroll` to reposition overlays. The app uses zone.js (`provideZoneChangeDetection()`). Moving the pointer across the chart makes the rest of the page sluggish: typing into a filter box lags and animations stutter. ## Why it happens The chart was created in a component hook, which runs inside the **Angular zone**. zone.js propagates that zone to everything the library schedules, so each `mousemove`, `scroll` and animation frame callback runs inside it. When each callback finishes, `NgZone` reports a completed task and Angular runs change detection from the root. At 60 pointer events per second, that is dozens of full checks per second for work that never changes Angular-rendered state. The Angular performance guide calls this **zone pollution** and names third-party libraries as a common cause. ## Confirming it 1. Open **Angular DevTools**, start the profiler, move the pointer over the chart and scroll, then stop. 2. Look for a dense sequence of change detection bars whose **source** is `mousemove`, `scroll`, `requestAnimationFrame`, `setTimeout` or an event handler. The guide describes exactly this pattern as the signature of zone pollution. 3. If your own templates bind few such events, the library is the likely source. Log `NgZone.isInAngularZone()` inside one of its callbacks; `true` confirms it runs inside the zone. The browser's performance panel will show the same thing as repeated change detection work inside input handlers, but the DevTools profiler names the trigger directly. ## Fixing it | Step | What it does | |---|---| | Create the chart inside `NgZone.runOutsideAngular()` | Every listener, timer and animation frame the library registers runs outside the zone and triggers nothing | | Do it in `afterNextRender` | The host element exists, and the code never runs during server rendering | | Re-enter with `NgZone.run()` for events that change app state | A clicked point updating a side panel still triggers one check | | Or set a signal in those handlers | Since v18 a zone-based app also schedules a check for signal writes made outside the zone | | Destroy the chart with `DestroyRef.onDestroy()` | Its listeners do not outlive the component | Re-entry must stay narrow. Wrapping the library's `mousemove` handler in `run()` would recreate the problem, since every move would re-enter the zone. Re-enter only for discrete events such as a click on a point, or only when a derived value actually changes. ## Your own high-frequency listeners The same pollution comes from application code. A template binding such as `(mousemove)="track($event)"` or a host listener on `window:scroll` always runs inside the Angular zone, and `runOutsideAngular()` cannot wrap a template binding. The remedy is to register those listeners yourself inside `runOutsideAngular()`, with `addEventListener` in `afterNextRender`, and update Angular state only when the value that the template shows changes, for example when a sticky header should toggle, not on every pixel of scroll. ## A checklist for wrapping any third-party widget - Create the widget in `afterNextRender`, inside `runOutsideAngular()`. - List the widget events the application genuinely needs; ignore the rest. - For each needed event, re-enter with `run()` or set a signal, and do it only when the value changes. - Tear the widget down with `DestroyRef.onDestroy()`. - Keep the wrapper free of template bindings to high-frequency events. - Profile the interaction before and after the change and keep the recording as evidence in the pull request. Following one checklist for every map, chart, editor and player keeps each team from rediscovering the same pollution. ## Verifying the fix Record the same interaction again. Moving the pointer and scrolling should produce **no** change detection bars, while clicking a data point should produce exactly one. If bars remain, check for remaining template or host listeners on high-frequency events, or a library call made outside `afterNextRender` that registered its listeners before the chart was moved out of the zone.
- The chart's point-click callback in the Angular example is registered outside runOutsideAngular(); why does it still run outside the zone?Because the library, not the component, attaches the DOM listener that eventually calls it, and a typical library attaches its DOM listeners while the chart is created, which here happened inside `runOutsideAngular()`. The zone is captured when a listener is registered, so the library's click handling, and the callback it invokes, run outside the Angular zone. That is why the example re-enters with `run()`; setting the signal alone would also schedule a check since v18.
- In a zone-based Angular app, why can runOutsideAngular() not fix a (mousemove) binding in your own template?Template listeners are registered by Angular while creating the view, inside the Angular zone, and there is no hook to move a template binding elsewhere. To take a high-frequency event out of the zone, register it yourself with `addEventListener` inside `runOutsideAngular()`, typically in `afterNextRender`, and update state only when the displayed value changes.
saying these in an interview costs you the question
- The lag must be the chart's own rendering; Angular is not involved.
- Adding OnPush to the chart component stops the extra checks.
- Wrap the library's mousemove handler in NgZone.run() so tooltips update.
- runOutsideAngular() can be applied to a (mousemove) template binding.
- afterNextRender callbacks already run outside the Angular zone.