Does moving an Angular application to zoneless change detection eliminate zone pollution, and which needless checks can still remain?
answer
- nothing watches timers any more
- runOutsideAngular becomes a plain call
- bound listeners still notify
- signal writes per event
basics
~20 sMostly 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.
solid answer
~50 sZone pollution exists because zone.js treats every finished task as a reason to check. A zoneless app, the default since v21, observes no tasks: timers, `requestAnimationFrame` loops and a third-party library's own listeners trigger nothing, so the classic pollution disappears and the `NgZone` injected is a no-op whose `runOutsideAngular()` and `run()` just call the function. Two sources remain. **Bound template and host listeners** still notify the scheduler and mark their view dirty, so `(mousemove)` or a host `window:scroll` listener schedules a check for each burst of events. **Signal writes per event**, such as setting a scroll-position signal a template reads on every pixel, schedule checks too. Zoneless checks are coalesced and targeted, so each is cheaper than a zone-triggered pass from the root, but the fix is the same: register high-frequency listeners manually, and write state only when the displayed value changes.
go deeper
Know that without zone.js, timers and library events no longer trigger change detection on their own.
Explain which notifications still schedule checks and why a bound (scroll) listener still costs one check per burst.
Audit for bound high-frequency listeners and per-event signal writes, and keep NgZone calls that zone-based consumers rely on.
Present zoneless as the structural cure for pollution and weigh it against the discipline it requires for explicit notifications.
## Why zoneless removes the classic problem **Zone pollution** is change detection triggered by tasks that change nothing on screen. It exists because zone.js patches asynchronous APIs and Angular runs a check whenever a task in the Angular zone completes, without knowing what the task did. A **zoneless** application (the default since v21, when `provideZoneChangeDetection()` is not used) has no such observer. Angular runs change detection only when one of its own APIs **notifies** it: a template-read signal is set, `markForCheck()` is called, a bound template or host listener fires, `ComponentRef.setInput()` is called, or a dirty view is attached. So: - a polling `setInterval` triggers nothing; - a charting library's internal `mousemove`, `scroll` and animation-frame handlers trigger nothing; - `requestAnimationFrame` loops, WebSocket heartbeats and analytics timers trigger nothing. The NgZone-based remedies lose their purpose. Zoneless bootstrap injects a **no-op `NgZone`**: `runOutsideAngular(fn)` and `run(fn)` simply call `fn`. The zoneless guide says to keep such calls, because removing them can cause regressions for libraries used in applications that still run zone.js. ## What can still cause needless checks | Source | Why it still checks | Remedy | |---|---|---| | `(mousemove)`, `(scroll)` or `(pointermove)` bound in a template | Bound listeners notify the scheduler and mark their view dirty on every event | Register the listener with `addEventListener` in `afterNextRender`; update state only when the shown value changes | | Host listeners such as `window:scroll` or `window:resize` | Host listeners are bound listeners too | Same as above | | A signal set on every event, such as scroll position | Each write read by a template schedules a check | Write a derived signal only when it changes (e.g. `isSticky`), or throttle | | `markForCheck()` called from a hot loop | Each call schedules a check | Call it only when state actually changed | ## Why the remaining checks are cheaper Even where checks remain, they cost less than zone-triggered ones: 1. **Coalescing.** The scheduler ignores further notifications while a check is already pending, so a burst of events before the scheduled check runs costs one check. 2. **Targeting.** A check triggered only by a signal write refreshes the views that read the signal instead of re-evaluating every `Eager` view from the root. 3. **No false triggers.** Tasks that notify nothing cost nothing, where zone.js charged a full pass for every one. That changes the diagnosis. In a zoneless app, a dense run of change detection cycles in the Angular DevTools profiler points at a bound listener or a signal written too often, not at a timer or a third-party library. ## The trade you accept Zoneless removes pollution by removing automatic detection, so the opposite mistake becomes possible: a third-party callback that *should* update the screen now does nothing unless it sets a signal or calls `markForCheck()`. Libraries initialised outside the zone in a zone-based app were already written this way, with explicit `run()` calls; in a zoneless app those `run()` calls are no longer what makes the update render, so the state they wrap should be a signal. ## Diagnosing in a zoneless app When a zoneless app still shows frequent change detection during an interaction: 1. Record the interaction with the Angular DevTools profiler and note the source of the cycles. 2. If the source is an event such as `mousemove` or `scroll`, search templates and `host` metadata for bindings to that event. 3. If cycles appear with no event source, look for a signal or `markForCheck()` call inside a hot path, such as an animation-frame loop or a WebSocket message handler that fires many times per second. 4. Move the listener out of the template or reduce the writes to the moments when the displayed value changes, then record again. ## A migration checklist for pollution - Leave existing `runOutsideAngular()` and `run()` calls in place. - Audit templates and host bindings for high-frequency events and move them to manual listeners. - Replace per-event signal writes with derived state that changes rarely. - Profile the same interactions before and after the switch to confirm the cycles are gone.
- Should an Angular library remove its NgZone.runOutsideAngular() calls once it supports zoneless applications?No. In a zoneless app those calls are harmless no-ops, while applications that still use zone.js depend on them to keep the library's timers and listeners from triggering checks. The zoneless guide says removing them can cause performance regressions for such consumers.
- In a zoneless Angular app, how would you implement a header that turns sticky after scrolling 200 pixels without a check per scroll event?Register a passive `scroll` listener with `addEventListener` in `afterNextRender` instead of a host or template binding, compute whether the offset exceeds 200 pixels, and set an `isSticky` signal only when that boolean flips. Remove the listener through `DestroyRef`. Scrolling then costs no checks except at the two crossing points.
saying these in an interview costs you the question
- Zoneless apps can never run needless change detection.
- A (scroll) template binding is free in a zoneless app.
- runOutsideAngular() throws in a zoneless app, so remove it.
- Zoneless removes pollution without any change to how UI updates are triggered.
- Every signal write triggers a full check of all components from the root.