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.