Angular Advanced #1 Change Detection — Default, OnPush, Zoneless
In the last post of the Intermediate series I previewed the seven topics we’d cover in Advanced. The first one I put on that list was Change Detection. It’s the mechanism that decides “why and when” the screen of an Angular app gets repainted, and it’s also the single biggest variable that splits performance in large apps.
In this post we’ll cover exactly what Change Detection is, how the Default strategy differs from the OnPush strategy, what role zone.js has been playing behind the scenes, and the Zoneless flow that has gained traction since Angular 18 — all in one go.
What Change Detection is #
In one line — it’s the mechanism that connects a data change to a screen repaint. When some value on a component changes, the templates that read that value automatically refresh with the new value. That flow we take for granted is exactly this.
Angular walks the component tree top-down, re-evaluates the template expressions on each component, and updates the DOM if the value differs from before. One pass of this walk is called a change detection cycle.
The question is “when do we run this cycle?” Run it too often and the app slows down; run it too rarely and the screen never refreshes. Angular has long solved this with the rule “once at the end of every async task.” What made that rule possible is zone.js (more on that in a moment).
Default strategy — walks the whole tree #
Without explicit configuration, every component runs with ChangeDetectionStrategy.Default. When change detection is triggered, it walks every component from the root down and re-evaluates each one’s bindings.
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-dashboard',
standalone: true,
template: `<p>{{ user.name }} — {{ score }}</p>`,
// changeDetection: ChangeDetectionStrategy.Default ← this is the default if you don't specify
})
export class DashboardComponent {
user = { name: 'Curtis' };
score = 1200;
}The upside is that it’s simple and safe. No matter how data changes, the screen will catch up on the next cycle. You almost never have to debug “why isn’t my screen updating?”
The downside is that the cost grows quickly as the app grows. Imagine a screen with hundreds of components, scanning the entire tree on a single keystroke — the burden adds up. Most components didn’t change anything during that cycle but still get checked.
OnPush strategy — only check part of the tree #
The fix is “only check when really needed.” A component marked ChangeDetectionStrategy.OnPush is only checked in these four cases:
- The reference of an
@Input(orinput()) on the component changed - An event fired on the component or one of its children (click, input, etc.)
- An
asyncpipe in the template emits a new value - A signal the component reads has changed
If none of those happens, the entire subtree is skipped. With OnPush placed well across a large app, the number of components checked per cycle drops dramatically.
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
template: `<p>{{ user().name }} — {{ user().score }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
user = input.required<{ name: string; score: number }>();
}OnPush requires immutable data #
OnPush detects @Input changes by reference comparison (===). If you mutate a field inside an object, the reference is the same and the OnPush component never notices.
// parent component
addPoint() {
this.user.score += 100; // mutating the same object
// → the OnPush child may not refresh
}addPoint() {
this.user = { ...this.user, score: this.user.score + 100 };
// → reference changed, so the OnPush child refreshes too
}The rule is simple. Treat data flowing into an OnPush component’s input as immutable. Patterns that build a new value and swap it whole (...spread, map, filter) feel natural here. In that sense, OnPush pairs very well with a functional style — and with signals.
NgZone and zone.js — the worker behind the scenes #
By this point a natural question comes up — “so when does Angular actually run change detection?” The thing that has been making that decision for us is zone.js.
zone.js is a library that monkey-patches the browser’s async APIs (setTimeout, Promise, addEventListener, XHR, etc.) so that “this async task started / ended” can all be detected. Angular calls tick() — that is, runs a change detection cycle — at every moment an async task ends inside the zone (NgZone) it’s running in.
That’s why the screen updates automatically without explicit calls like setState. Writing just this.count++ in a click handler refreshes the screen because zone.js caught the moment the click event ended and told Angular “now’s the time to check.”
The cost is heavy. zone.js intercepts every async API, so it brings bundle size (about 30 KB gzip) and runtime overhead. And because change detection frequency is always tied 1:1 with async tasks, cycles run even when there’s nothing on screen to update.
runOutsideAngular — working outside the zone
#
In a zone-based app, a tool you’ll often meet when handling high-frequency events (scroll, mouse move, requestAnimationFrame loops, etc.) is NgZone.runOutsideAngular. As the name says, it runs the callback outside Angular’s zone so that change detection isn’t triggered.
import { Component, NgZone, inject, OnInit, DestroyRef } from '@angular/core';
@Component({
selector: 'app-scroll-tracker',
standalone: true,
template: `<p>Scroll Y: {{ y }}</p>`,
})
export class ScrollTrackerComponent implements OnInit {
private zone = inject(NgZone);
private destroyRef = inject(DestroyRef);
y = 0;
ngOnInit() {
this.zone.runOutsideAngular(() => {
const onScroll = () => {
// changing this.y here doesn't repaint — change detection isn't running
// come back into the zone with zone.run only when you really want to update the screen
if (window.scrollY % 100 === 0) {
this.zone.run(() => (this.y = window.scrollY));
}
};
window.addEventListener('scroll', onScroll, { passive: true });
this.destroyRef.onDestroy(() => {
window.removeEventListener('scroll', onScroll);
});
});
}
}Even if scroll fires every frame, change detection only runs once every 100 pixels. For cases where the event frequency is much higher than the screen refresh rate — scrolling, dragging, chart animations — this one pattern alone makes a noticeable difference.
Zoneless — runs without zone.js #
In Angular 18, Zoneless mode — running without zone.js — entered serious experimental territory. The core idea is simple — “now that signals send the notifications themselves, the zone doesn’t need to monitor every async task.”
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
// ...
],
};If you remove zone.js from angular.json or the polyfills config, you really get an app without zones. From that point, when change detection runs is decided by these explicit signals:
- Signal changes —
signal.set(...),signal.update(...) - A new value from the
asyncpipe - Event bindings (
(click), etc.) - Explicit calls like
ChangeDetectorRef.markForCheck()
Removing zone.js drops about 30 KB from the bundle, and a slight performance gain comes from “useless cycles” disappearing. The catch is that if you just change a field without a signal or an explicit notification, the screen doesn’t refresh. So Zoneless is essentially a model that assumes OnPush + signals.
provideExperimentalZonelessChangeDetection (when Experimental drops from the name, that’s the signal it has stabilized). When introducing it in a new project, check that the libraries you use don’t depend on the zone (especially RxJS schedulers, setTimeout-based utilities, etc.) once.Debugging Change Detection #
When you write code centered on OnPush and signals, sometimes you’ll hit a “why isn’t my screen updating?” or “why is this running so often?” moment. Keep two tools on hand.
ng.profiler.timeChangeDetection()— In dev mode, typeng.profiler.timeChangeDetection()in the console and it measures the average time of one change detection cycle in ms. Useful for comparing before and after applying OnPush.- Angular DevTools — The Chrome extension. The Profiler tab visualizes which component was checked in which cycle. It’s the fastest way to answer questions like “this component is OnPush — why is it being checked every time?”
> ng.profiler.timeChangeDetection()
ran 500 change detection cycles in 312 ms; 0.624 ms per checkPractical guide — so how should I use this? #
I’ll wrap up with three recommendations.
Make OnPush the default in new projects. Add
--change-detection=OnPushto your component generator, or make it a team convention to setOnPushon every component. Starting from OnPush makes immutable patterns settle in naturally.Use signals as the default tool for inputs and state. The OnPush + signals combo wipes out most of the “headaches from reference comparison” of classic OnPush. The
effect()andinput()we saw in Intermediate #4 really shine at this point.Seriously consider Zoneless for new projects, but migrate existing apps step by step. The safe order is — library compatibility check → full OnPush adoption → gradual move to signals → enable Zoneless. Removing zone.js should be the last step.
Wrap-up #
In this post we did a one-shot tour of Change Detection, the heart of Angular performance.
- Change Detection is the mechanism that connects data changes to screen updates
- Default walks the entire tree. Simple and safe, but costly in large apps
- OnPush only checks on input reference changes, events, async pipe, or signal changes
- OnPush assumes immutable data — swap to a new object or use signals
- zone.js + NgZone has been deciding “when to check”
- High-frequency events go outside the zone with
runOutsideAngular - Zoneless is the model where signals, events, and the async pipe send notifications directly, without zone.js
- Debug with
ng.profiler.timeChangeDetectionand the Angular DevTools Profiler
In the next post — “Angular Advanced #2 Signals in depth — effect, computed, input,model, Resource API” — we’ll dig into the signals that kept showing up here. The fine-grained behavior rules of effect(), how computed() handles its cache, input() as a component input and model() for two-way binding, and the Resource API that’s becoming the new standard for handling async data with signals — we’ll go one level deeper into the grain of signals.