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.

TriggerZonedZoneless
Signal writeCheck scheduledCheck scheduled
Template event handlerCheck scheduledCheck scheduled
setTimeout outside AngularCheck scheduled (via the patch)Nothing — you must notify
Promise resolution in a serviceCheck scheduledNothing unless it writes a signal
await in a component methodCheck scheduledNothing unless state is a signal
Third-party callbackCheck scheduledNothing — convert the state to a signal
markForCheck()Check scheduledCheck 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);
  }
}
💡
Migrating a large app to zoneless is mostly a search for state that is not a signal: an array mutated in place, an object property assigned in a callback, a subscription that only calls 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>();
}
TechniqueEffectCost
OnPush on leaf componentsSkips the subtree unless an input changesRequires immutable inputs or signals
track on @forReuses DOM nodes on reorderNeeds a stable id
computed instead of a method in the templateCaches the resultNone worth mentioning
@deferRemoves work from the first renderOne extra network round trip
NgOptimizedImageCorrect sizing and lazy loadingRequires width and height
Bulk signal write with untrackedAvoids cascading effectsEasier 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 computed and the cost disappears.
  • OnPush plus 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 OnPush than @Input properties 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
SignalMeaningAction
Initial bundle over budgetSomething heavy is imported eagerlyMove it behind @defer or lazy routes
One component style over budgetA large embedded stylesheetSplit the component or move styles out
anyComponentStyle warning on buildView encapsulation duplicated a big blockCheck for a shared @import in many components
Angular DevTools shows many checksChange detection is running too broadlyAdd OnPush, convert state to signals
Long tasks in the Performance panelSynchronous work in a renderMove it out of the template; computed is lazy, not free
A slow first paint onlySSR not enabled or a blocking fontEnable 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));  // fine

The 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?
Yes for applications that keep their state in signals. The failure mode is always the same: state updated by a library callback that Angular never hears about. Audit third-party integrations before switching, and keep an eye on views that stop updating.
Why did my view stop updating after adding OnPush?
The input object was mutated in place, so the reference did not change and Angular skipped the subtree. Either replace the object, use a signal input, or inject ChangeDetectorRef and call markForCheck() — the first two are better answers.

Template control flow, pipes and deferred views Signals, computed values and effects

Last refreshed 2026-09-18.