Angular – Change Detection, OnPush and Zoneless

August 11, 20264 min readUpdated 8/21/2026

Change detection is Angular deciding which templates to re-render. Understanding it is the difference between an app that stays fast and one that mysteriously does not.

How it used to work

Angular could not know what had changed, so Zone.js patched every async API in the browser — setTimeout, every event listener, every XHR — and after anything happened, Angular re-checked the entire component tree and diffed the results against the last render.

It worked, and it did work proportional to the size of your application rather than the size of the change. A mousemove listener could trigger a full-tree check per pixel.

OnPush

changeDetection: ChangeDetectionStrategy.OnPush,

OnPush tells Angular to skip this component unless an input reference changed, a signal it read changed, or an event fired inside it. Every one of the thirty components in the pizza app sets it.

The React comparison is exact and instructive. ProductCard's React counterpart is wrapped in React.memo, and the menu page has to wrap its onSelect handler in useCallback for the memo to hit at all — an inline arrow would be a new function identity every render and the comparison would never succeed.

OnPush needs neither. A handler bound with (selected)="…" is not an input, so it cannot invalidate anything. There is no useCallback in Angular because there is nothing for it to fix.

⚠️ The one thing OnPush punishes is mutation. If you mutate an object passed as an input, the reference is unchanged and the child never re-renders. Replace, do not mutate — the same rule signals impose.

Zoneless

Signals know exactly which templates read them, which makes Zone.js unnecessary. The pizza app has no zone.js installed at all.

The practical gains: about 13 kB less JavaScript, no monkey-patching of browser APIs at startup, cleaner stack traces, and better interop with anything outside Angular. The cost is that change detection now only happens for reasons Angular can see — a signal write, an event binding, an async pipe — so state held in a plain field and mutated from a callback will not repaint. In practice that means "put state in signals", which you wanted anyway.

@defer

Block syntax for loading part of a template lazily:

@defer (on viewport) {
  <app-revenue-chart [data]="revenue()" />
} @placeholder {
  <div class="chart-skeleton"></div>
} @loading (minimum 200ms) {
  <app-spinner />
}

The component inside becomes its own chunk, fetched when the trigger fires. Triggers include on idle (the default), on viewport, on interaction, on hover, on timer, and when with an expression. @placeholder shows before, @loading during, @error if the fetch fails.

minimum 200ms on the loading block prevents the flicker of a spinner that appears and vanishes within a frame — worth setting nearly always.

The pizza app does not use @defer. Its heavy things are whole routes, and those are already lazy — which is the more common answer. @defer earns its place when something expensive sits inside a route the user needs immediately: a chart below the fold, a rich editor behind a tab.

Route-level splitting

loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),

This is where the real wins are, and they are measurable. The admin area — six screens, NgRx, ten effects — is a chunk that only an admin ever downloads.

track, again

@for (product of visibleProducts(); track product.id) {

Worth restating in a performance lesson: without stable identity, a reordered list is destroyed and rebuilt rather than moved, losing focus, scroll position and any state inside those components. This is the most common list-performance bug in any framework, and Angular now makes it a compile error to omit the answer.

Measuring

Angular DevTools has a profiler that shows which components were checked and how long each took. That is the tool for "why is this slow" — not guesswork, and not adding OnPush everywhere and hoping.

ng build prints every chunk with its raw and transferred size. Read it after any change to routing or providers: a route you expected to be lazy that is not in the list means something is importing it eagerly.

Budgets in angular.json fail the build when a bundle grows past a threshold, which is how you find out about a regression before your users do. That is the next lesson.

The order to do things in

Put state in signals. Set OnPush everywhere — it is free and it is a habit. Lazy load routes. Then, and only then, measure before optimising anything else. Most Angular performance problems in a signals-first zoneless app are not change detection at all; they are a request that should have been cancelled, or a list that should have been paginated.

What is next

Getting it into production.