Frontend interview practice question

Angular Change Detection Strategies: Visualize Default vs OnPush

HighIntermediateAngular

Direct answer

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.

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.

  1. 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.
  2. Service timer with an OnPush footer: Zone.js runs a pass, but nothing marked LiveCount, so Angular skips it. Fix: markForCheck().
  3. subscribe() without a mark: the callback assigns a field on an OnPush card and the view stays stale. Fix: the async pipe.
  4. Signal inside OnPush: one view refreshes; its OnPush ancestors are traversed, not re-rendered.
  5. 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
Practice more Angular interview questions
Interview answer drill

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

ChangeDetectionStrategy.Default

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.

ChangeDetectionStrategy.OnPush

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.

Default vs OnPush in real apps
TYPESCRIPT
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.

TYPESCRIPT
@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 async pipe

✅ Yes

✅ Yes

AsyncPipe calls markForCheck() on every emission.

A signal read in the template changes

✅ Yes

✅ Yes, that view only

Ancestors are traversed, not re-rendered.

markForCheck()

✅ Yes

✅ Yes

Marks the view and its ancestors for the next pass.

detectChanges()

✅ Yes, this subtree

✅ Yes, this subtree

Synchronous check of this view and its children; views outside stay as they are.

setTimeout or manual subscribe() assigns a field, Zone.js app

✅ 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 runOutsideAngular()

❌ No pass

❌ No pass

Zone.js never sees the task finish.

Compact trigger matrix

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.

TYPESCRIPT
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 markForCheck()

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, markForCheck(), an event, or an async pipe

Zoneless schedules passes only for explicit notifications.

Profiling-first debug checklist

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

  • Default favors simplicity; OnPush favors predictable performance in large trees.
  • OnPush still updates on events, async pipe 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, and setInput() 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.

Guides
Preparing for interviews?