Angular has two change detection strategies. Default checks a component on every change detection pass that reaches it. OnPush checks a component only when its input reference changes, an event fires inside its subtree, an async pipe emits, a signal it reads changes, or code calls markForCheck(). Most production stale-UI bugs are OnPush components whose data changed without one of those triggers: an object mutated in place, a field assigned inside a manual subscribe(), or a timer in a zoneless app that notified nobody. Run each bug in the visualizer below and watch which of the seven components Angular checks, skips, or only traverses.
Frontend interview practice question
Angular Change Detection Strategies: Visualize Default vs OnPush
Direct answer
Interactive Angular change detection lab
Trace which components Angular checks after each trigger
Default views are checked on every pass that reaches them. OnPush views are checked only after a new input reference, an event in their subtree, an async pipe emission, a signal they read, or markForCheck(). Without Zone.js, only those notifications schedule a pass at all. The lab replays five production bugs on a seven-component tree and shows which views Angular checks, skips, or only traverses.
- Array push with an OnPush list: the parent mutates the same array, so UserList is skipped and keeps the old rows. Fix: pass a new reference.
- Service timer with an OnPush footer: Zone.js runs a pass, but nothing marked LiveCount, so Angular skips it. Fix: markForCheck().
- subscribe() without a mark: the callback assigns a field on an OnPush card and the view stays stale. Fix: the async pipe.
- Signal inside OnPush: one view refreshes; its OnPush ancestors are traversed, not re-rendered.
- Zoneless timer: no signal, event, async pipe, or markForCheck() ran, so no pass is scheduled. Fix: store the value in a signal.
Interview focus
This Angular interview question tests whether you can explain Angular Change Detection Visualizer: Default vs OnPush, connect it to production trade-offs, and handle common follow-up questions.
- Angular Change Detection Visualizer: Default vs OnPush explanation without falling back to memorized definitions
- Change Detection and Performance reasoning, edge cases, and production failure modes
- How you would answer the most likely Angular interview follow-up
Use this Angular interview question to rehearse a quick answer, common mistake, follow-up, and production pitfall.
Trigger rules, zoneless migration, and DevTools proof
Read the visualizer trace from the root
The lab above walks a seven-component tree the way Angular does: depth first from AppComponent, deciding at every view whether to check it, skip it, or only pass through it. Three outcomes explain almost every change detection question. A checked view had its template re-evaluated, and its DOM updated if a binding changed. A skipped view is OnPush with the same input reference and no dirty flag, so Angular ignored it together with its whole subtree. A traversed view is an OnPush ancestor on the path to a signal refresh: Angular walks through it without re-evaluating its template. A value turns stale when the data changed but the view was skipped, or when no pass ran at all.
Default vs OnPush: the production decision
The real question is not “what is change detection?” but when should this subtree be checked. Default is the safe baseline when you want fewer stale-UI surprises. OnPush is the performance strategy when you want predictable checks, immutable inputs, and fewer wasted passes through a large component tree. Angular keeps a tree of views. On each pass it decides which branches need checking, evaluates bindings, and updates the DOM where values changed. That is why the strategy choice matters far more in a large tree than in a demo component, and why interviewers want the trade-off, not just the two names.
Strategy | What Angular does | Where it wins | Common production bug |
|---|---|---|---|
| Checks the component on every change detection pass that reaches its branch. | Simpler state model and fewer stale-view surprises. | Large trees re-render too often because every view stays eligible for checks. |
| Checks the component when an input reference changes, an event happens in the subtree, an async pipe emits, a signal it reads changes, or you mark the view manually. | Large lists, reactive UIs, and predictable immutable state flows. | UI looks stale because code mutated an object or assigned state in a manual subscription without marking the view. |
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `<p>{{ user.name }}</p>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
@Input() user!: { name: string };
}
This child is OnPush, so Angular cares about reference changes and explicit triggers, not deep mutation. If a parent does user.name = 'Bob' on the same object, the child stays stale even though the data changed. The first preset in the visualizer, the array push with an OnPush list, is this bug at list scale: the parent is checked, UserList is skipped, and the new row never renders.
@Component({
selector: 'app-parent',
template: `
<app-user-card [user]="user"></app-user-card>
<button (click)="renameWrong()">Wrong</button>
<button (click)="renameCorrect()">Correct</button>
`
})
export class ParentComponent {
user = { name: 'Alice' };
// ❌ Same object reference: an OnPush child can stay stale
renameWrong() {
this.user.name = 'Bob';
}
// ✅ New reference: OnPush sees a meaningful input change
renameCorrect() {
this.user = { ...this.user, name: 'Bob' };
}
}
Why OnPush still updates after events and async work
OnPush does not mean “never update unless an input changes.” Events inside the subtree, async pipe emissions, signals the template reads, and manual CDR calls still mark the view dirty or request a refresh. That is why a stale card sometimes “wakes up” after a click or an Observable emission: the click marked the branch dirty, so the pending data finally rendered. In the visualizer, fire Click inside on a stale OnPush card and watch it update for a reason that has nothing to do with the data.
Trigger | Default | OnPush | Why it matters |
|---|---|---|---|
Parent mutates an existing object | ✅ Visible on the next pass | ❌ Skipped | OnPush compares input references, not deep object state. |
Parent passes a new input reference | ✅ Yes | ✅ Yes | A new reference is a meaningful input change. |
Event happens inside the component subtree | ✅ Yes | ✅ Yes | Angular marks that branch and its ancestors dirty. |
Observable emits through the | ✅ Yes | ✅ Yes | AsyncPipe calls |
A signal read in the template changes | ✅ Yes | ✅ Yes, that view only | Ancestors are traversed, not re-rendered. |
| ✅ Yes | ✅ Yes | Marks the view and its ancestors for the next pass. |
| ✅ Yes, this subtree | ✅ Yes, this subtree | Synchronous check of this view and its children; views outside stay as they are. |
| ✅ Next pass | ❌ Skipped | Zone.js schedules the pass, but nothing marked the OnPush view. |
The same assignment in a zoneless app | ❌ No pass | ❌ No pass | Nothing notified Angular, so no pass is scheduled at all. |
Timer inside | ❌ No pass | ❌ No pass | Zone.js never sees the task finish. |
markForCheck() vs detectChanges()markForCheck() says “include me and my ancestors in the next normal pass.” detectChanges() says “check my subtree right now.” If you reach for detectChanges() by default, you usually have a lifecycle-timing smell rather than a true need for synchronous rendering. The visualizer shows the second trap: detectChanges() on UserList checks that subtree only, so a stale LiveCount outside it stays stale until a normal pass includes it.
import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core';
@Component({
selector: 'app-live-count',
template: `{{ total }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LiveCountComponent {
total = 0;
constructor(private cd: ChangeDetectorRef) {}
connectToExternalCallback(source: { onMessage(cb: (n: number) => void): void }) {
source.onMessage(n => {
this.total = n;
this.cd.markForCheck();
});
}
flushImmediatelyAfterViewMutation() {
this.total++;
this.cd.detectChanges();
}
}
Zoneless change detection: what changes
Zone.js patches browser APIs so that every finished task, whether a timer, an XHR callback, or an event, schedules a pass from the root. Zoneless Angular removes that patching. Angular 21 creates new applications zoneless by default, and Angular 20 apps opt in with provideZonelessChangeDetection() while removing zone.js from the build and test polyfills. Without the zone, only explicit notifications schedule a pass: a signal that a template reads changes, markForCheck() runs (the async pipe calls it for you), a template or host event listener fires, ComponentRef.setInput() is called, or a view that was marked dirty is attached. A plain field assignment in a setTimeout or a manual subscribe() callback notifies nobody, which is why the zoneless preset in the visualizer shows zero views checked. The migration rule is short: keep state that templates read in signals, keep Observables behind the async pipe, and treat every leftover markForCheck() as a hint that a signal would remove it. NgZone.run() and runOutsideAngular() can stay; they do no harm in a zoneless app. Current TestBed runs zoneless by default, so tests should await fixture.whenStable() instead of calling fixture.detectChanges() out of habit.
Prove it with Angular DevTools
Open the Angular tab in Chrome or Firefox DevTools, switch to Profiler, and click Start recording. Each bar is one change detection cycle; a taller bar means Angular spent longer in that pass. Select a bar to open the flame graph, where each tile is a component at its position in the render tree and the color intensity shows how much time Angular spent there. Turn on Show only change detection to grey out the views that did not go through change detection, such as OnPush components that did not re-render. That greyed-out tile is the skipped outcome in the visualizer. When a stale bug reproduces on staging, Save Profile exports the recording as JSON so a reviewer can load it with Choose file and see which component the pass never touched.
Follow-up confusion that comes up in real debugging
ViewChild or direct child mutation: if you grab a child instance and mutate state directly, Angular may not treat that as a meaningful OnPush input change, so you often need a new reference or markForCheck().
ExpressionChangedAfterItHasBeenCheckedError: this usually means you changed bound state during the same detection turn after Angular already checked it. The fix is usually to move the update earlier, schedule it to the next turn, or rethink the data flow instead of blindly calling detectChanges() everywhere. In a zoneless app the same error also appears when a template value changes without any change notification, which points at a field that should have been a signal.
Traversed is not checked: when a signal refreshes one OnPush view, its OnPush ancestors appear in the pass but their templates are not re-evaluated. Angular DevTools shows them greyed out, and the visualizer labels them traversed.
Profiling question | Check first | Why |
|---|---|---|
Why is this list re-rendering too often? | Angular DevTools and whether child subtrees should be OnPush | You want evidence before changing strategy. |
Why does this OnPush child stay stale? | Look for in-place mutation or manual subscriptions without | Those are the two most common real bugs. |
Why did this expression-changed error appear? | Check lifecycle timing and state writes after Angular already checked the view | The problem is often timing, not missing brute-force detection. |
Why does nothing update after the zoneless migration? | Find the state write that is not a signal, | Zoneless schedules passes only for explicit notifications. |
Run the production bugs next
The OnPush production bug walkthrough tells the two stale-UI stories behind the first and third presets in full, with broken and fixed code side by side. How Zone.js schedules change detection explains the patching that the zoneless preset removes. The lifecycle hooks page covers the timing behind ExpressionChangedAfterItHasBeenCheckedError, and the HttpClient cancellation lab shows the other half of stale UI: responses that arrive after the user stopped caring.
Summary
Defaultfavors simplicity;OnPushfavors predictable performance in large trees.- OnPush still updates on events,
asyncpipe emissions, signals, and manual CDR APIs, but it skips deep mutation on the same input reference. markForCheck()is the normal manual escape hatch;detectChanges()is the sharper synchronous tool and only covers its own subtree.- Zoneless removes the automatic pass after every task: only signals,
markForCheck(), events, async pipes, andsetInput()schedule one. - The highest-signal production bugs are stale OnPush UIs from mutation, manual subscription updates without marking, timers in zoneless apps, and lifecycle-timing errors like
ExpressionChangedAfterItHasBeenCheckedError.
Use this as one explanation rep, then continue with the Angular interview questions cluster or a guided prep path.