Change detection, zoneless and performance
How rendering is scheduled without Zone.js, what OnPush changes, how to profile a slow view, and how to keep a bundle inside its budget.
How change detection works without Zone.js
Zone.js patches every asynchronous browser API so Angular knows when something might have changed, then checks the whole tree. Zoneless removes the patch: Angular re-renders when a signal it is tracking is written, when an event handler inside a template runs, or when you explicitly ask it to. Nothing else triggers a check.
| Trigger | Zoned | Zoneless |
|---|---|---|
| Signal write | Check scheduled | Check scheduled |
| Template event handler | Check scheduled | Check scheduled |
setTimeout outside Angular | Check scheduled (via the patch) | Nothing — you must notify |
| Promise resolution in a service | Check scheduled | Nothing unless it writes a signal |
await in a component method | Check scheduled | Nothing unless state is a signal |
| Third-party callback | Check scheduled | Nothing — convert the state to a signal |
markForCheck() | Check scheduled | Check scheduled |
import { Component, signal, ChangeDetectorRef, inject } from '@angular/core';
@Component({
selector: 'app-clock',
template: `<p>{{ now() }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ClockComponent {
private readonly cdr = inject(ChangeDetectorRef);
readonly now = signal(new Date().toISOString());
constructor() {
// WRONG in a zoneless app: nothing tells Angular to re-render.
setInterval(() => { this.now.set(new Date().toISOString()); }, 1000);
// RIGHT: the signal write is the notification. This is the whole point.
setInterval(() => {
this.now.set(new Date().toISOString());
// markForCheck() is only needed if you mutate non-signal state
// that the template reads:
// this.cdr.markForCheck();
}, 1000);
}
}console.log and then pokes an old field. Convert that state to signals and the app becomes correct rather than merely faster.OnPush and view boundaries
<!-- Slice a hot list: only the changed row is re-rendered -->
@for (row of rows(); track row.id) {
<app-row [row]="row" (select)="select(row.id)" />
}import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-row',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<td>{{ row().name }}</td>
<td>{{ row().total }}</td>
<button (click)="select.emit(row().id)">Open</button>
`
})
export class RowComponent {
// Signal inputs: writing a new row object is a notification.
readonly row = input.required<Row>();
readonly select = output<string>();
}| Technique | Effect | Cost |
|---|---|---|
OnPush on leaf components | Skips the subtree unless an input changes | Requires immutable inputs or signals |
track on @for | Reuses DOM nodes on reorder | Needs a stable id |
computed instead of a method in the template | Caches the result | None worth mentioning |
@defer | Removes work from the first render | One extra network round trip |
NgOptimizedImage | Correct sizing and lazy loading | Requires width and height |
Bulk signal write with untracked | Avoids cascading effects | Easier to get wrong than to get right |
- A method call in a template runs on every change detection pass, including for rows that did not change. Move it to a
computedand the cost disappears. OnPushplus mutable input objects is the classic combination that makes a view silently stop updating. Treat inputs as immutable, or use signals.- Signal inputs notify Angular automatically, which is why they play better with
OnPushthan@Inputproperties ever did.
Profiling and budgets
# Production build with a stats file for bundle inspection
ng build --configuration production --stats-json
npx source-map-explorer dist/billing-portal/browser/*.js
# Check whether a dependency pulled in something unexpected
npx esbuild-visualizer --metadata dist/*/stats.json
# Find the components that dominate the initial bundle
grep -o 'chunk-[A-Z0-9]*.js' dist/billing-portal/browser/index.html| Signal | Meaning | Action |
|---|---|---|
| Initial bundle over budget | Something heavy is imported eagerly | Move it behind @defer or lazy routes |
| One component style over budget | A large embedded stylesheet | Split the component or move styles out |
anyComponentStyle warning on build | View encapsulation duplicated a big block | Check for a shared @import in many components |
| Angular DevTools shows many checks | Change detection is running too broadly | Add OnPush, convert state to signals |
| Long tasks in the Performance panel | Synchronous work in a render | Move it out of the template; computed is lazy, not free |
| A slow first paint only | SSR not enabled or a blocking font | Enable prerendering; self-host and preload fonts |
// Profiling in code: count how often a hot computed actually runs.
import { computed, signal } from '@angular/core';
const rows = signal<Row[]>([]);
let runs = 0;
export const visibleRows = computed(() => {
runs++;
if (runs % 100 === 0) console.debug('visibleRows computed runs:', runs);
return rows().filter((row) => !row.hidden);
});
// If the count climbs with unrelated interactions, something is writing
// the whole array instead of updating one item:
// rows.set([...rows()]); // everything recomputes
// rows.update(list => list.map(r => r.id === id ? {...r, hidden: true} : r)); // fineThe cheapest performance work is almost always structural: fewer components rendered, fewer template function calls, fewer eager imports. Micro-optimising a change detection pass that should not be running in the first place is a waste of effort.
FAQ
Is zoneless production-ready?
Why did my view stop updating after adding OnPush?
ChangeDetectorRef and call markForCheck() — the first two are better answers.Related
Template control flow, pipes and deferred views Signals, computed values and effects
Last refreshed 2026-09-18.