In a zone-based Angular app, why does a setInterval that changes nothing on screen still make Angular run change detection on every tick?
answer
- zone.js cannot see your intent
- every finished task counts
- the whole tree gets checked
- run the timer outside the zone
basics
~20 szone.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.
solid answer
~50 sIn an app that uses `provideZoneChangeDetection()`, zone.js wraps asynchronous APIs such as `setTimeout`, `setInterval`, `requestAnimationFrame` and event listeners. When a callback started inside the Angular zone finishes and no microtasks remain, `NgZone` reports it and Angular runs change detection from the root. zone.js knows *that* a task ran, not *whether* it changed anything, so a polling `setInterval` every 500 ms that only writes to a cache or sends analytics still triggers a check every 500 ms. Eager views are re-evaluated each time; OnPush subtrees are skipped unless dirty, but the traversal still runs. Angular's docs call these needless checks **zone pollution**. The fix is to start the timer inside `NgZone.runOutsideAngular()`: its callbacks then run in the parent zone and trigger nothing, and you re-enter with `NgZone.run()` only when a callback really changes what the user sees.
go deeper
Recall that in a zone-based app any timer or event handler can trigger change detection, even if nothing visible changed.
Explain why zone.js cannot tell useful tasks from useless ones, what zone pollution means, and how runOutsideAngular() stops it.
Estimate when pollution matters (frequency, Eager views, template cost) and decide which work belongs outside the zone.
Weigh ongoing zone hygiene against a zoneless migration, which removes this whole class of problem.
## How a zone-based app decides to check Applications that opt into zone.js with `provideZoneChangeDetection()` (the only mode before v21, and still common in existing code) rely on **zone.js** to know when to run change detection. zone.js patches the browser's asynchronous APIs, so every timer callback, promise continuation, DOM event handler and XHR completion that was *started* inside the Angular zone runs inside it too. When such a task finishes and the microtask queue is empty, `NgZone` signals Angular, and Angular runs a change detection pass from the root of the component tree. The mechanism is deliberately blind. zone.js knows that a task ran; it does not know whether the task touched any state a template reads. ## What the timer costs ```ts import {Component, OnInit} from '@angular/core'; @Component({selector: 'app-dashboard', template: `...`}) export class Dashboard implements OnInit { ngOnInit() { // Runs inside the Angular zone: every tick triggers change detection. setInterval(() => this.heartbeat(), 500); } private heartbeat() { navigator.sendBeacon('/api/heartbeat'); } } ``` Every 500 ms: 1. The interval callback runs inside the Angular zone. 2. zone.js reports the task complete. 3. Angular traverses the component tree from the root, refreshing `Eager` views and dirty `OnPush` views (a clean `OnPush` component skips its whole subtree). 4. Nothing on screen changes. On a small page this is invisible. On a large dashboard with many `Eager` components, expensive template expressions or a timer running every animation frame, it adds up to measurable main-thread time and battery drain. ## Why it is called zone pollution The Angular performance guide calls these checks **zone pollution**: change detection triggered by tasks that do not change the data model. The usual sources are: - `setTimeout`, `setInterval` and `requestAnimationFrame` loops; - polling, heartbeat and analytics timers; - **third-party libraries** that schedule timers or add event listeners (charts, maps, editors) when initialised inside the zone; - high-frequency DOM events, such as `scroll`, `mousemove` and `resize`, handled inside the zone. ## The fix: start it outside the zone ```ts private readonly zone = inject(NgZone); ngOnInit() { this.zone.runOutsideAngular(() => setInterval(() => this.heartbeat(), 500)); } ``` `NgZone.runOutsideAngular(fn)` runs `fn` in the zone's **parent**, so the interval is scheduled there, and every later callback runs there too. Angular no longer checks after each tick. If a later callback does need to update the screen, wrap just that part in `NgZone.run()` to re-enter the Angular zone and trigger a check. | Timer started | Checks per tick | When to use | |---|---|---| | Inside the Angular zone | One full check | The callback changes displayed state every time | | With `runOutsideAngular()` | None | Work invisible to the UI: polling caches, beacons, logging | | Outside, re-entering with `run()` when needed | Only when re-entering | Mostly invisible work that occasionally updates the UI | ## Finding the timers Polluting timers are rarely as obvious as a bare `setInterval` in a component. Look for: - RxJS `interval()` and `timer()` subscribed inside the zone; they schedule real timers underneath, so each emission is a zone task; - polling services that start their loop from a component hook or an app initializer; - `requestAnimationFrame` loops for canvas drawing, counters or custom animations; - analytics, logging and session-keepalive SDKs started during bootstrap; - debounce helpers that reschedule a `setTimeout` on every keystroke. Each of these can be started inside `runOutsideAngular()` without changing its behaviour. The Angular DevTools profiler confirms the result: the regular change detection cycles with a timer as their source stop appearing. ## A note on zoneless apps In a zoneless app (the default since v21) timers are not observed at all, so this class of pollution disappears. `runOutsideAngular()` then simply calls the function, which is why keeping such calls is harmless.
- In a zone-based Angular app, does OnPush make zone pollution harmless?It reduces the cost but does not remove it. Each polluting task still starts a change detection pass from the root; OnPush lets Angular skip subtrees that are not dirty, so less template work runs. Eager components and the traversal itself still cost time on every tick. Running the task outside the zone removes the pass entirely.
zone.js is a doorbell wired to every door in the building: a courier dropping off a leaflet rings it just as loudly as a guest. runOutsideAngular() is a side entrance with no bell for deliveries nobody needs to answer.
saying these in an interview costs you the question
- Angular only runs change detection when data actually changes.
- A timer that updates nothing on screen costs nothing in a zone-based app.
- OnPush components stop the change detection pass from starting.
- runOutsideAngular() makes the callback run in a Web Worker.
- Clearing the interval on destroy fixes the extra checks.