Angular change detection strategy is a production performance and debugging choice, not just a framework definition. The useful answer compares Default versus OnPush through stale-UI bugs, trigger rules, immutable updates, and when to use markForCheck() or detectChanges().
Use this Angular interview question to rehearse a quick answer, common mistake, follow-up, and production pitfall.
What are change detection strategies in Angular, and how do they work?Frontend interview answer
This Angular interview question tests whether you can explain Angular change detection strategies in production: Default vs OnPush, triggers, and stale-UI bugs, connect it to production trade-offs, and handle common follow-up questions.
- Angular change detection strategies in production: Default vs OnPush, triggers, and stale-UI bugs explanation without falling back to memorized docs wording
- Change Detection and Performance reasoning, edge cases, and production failure modes
- How you would answer the most likely Angular interview follow-up
Production decision first
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.
How Angular thinks about it
Angular keeps a tree of views. On each detection pass it decides which branches need checking, evaluates bindings, and updates the DOM if values changed. That is why strategy choice matters more in large trees than in tiny demo components.
Two strategies, two trade-offs
Interviewers usually want the architecture trade-off, not just the names:
Strategy | What Angular does | Where it wins | Common production bug |
|---|---|---|---|
| Checks the component whenever Angular runs a normal change-detection pass for that branch. | Simpler state model and fewer stale-view surprises. | Large trees rerender too often because everything stays eligible for checks. |
| Checks mainly when an input reference changes, an event happens in the subtree, an async pipe emits, or you mark the view manually. | Large lists, reactive UIs, and predictable immutable state flows. | UI looks stale because code mutated an object or updated 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 can stay stale even though the data technically changed.
@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, and manual CDR calls still mark the view dirty. That is why a stale card sometimes “wakes up” after a click or Observable emission.
Trigger | Default | OnPush | Why it matters |
|---|---|---|---|
Parent mutates existing object | ✅ Usually visible on next pass | ❌ Often 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 dirty. |
Observable emits through | ✅ Yes | ✅ Yes | AsyncPipe internally marks the view for checking. |
| ✅ Yes | ✅ Yes | Schedules the branch for the next normal pass. |
| ✅ Yes | ✅ Yes | Forces an immediate synchronous check on this subtree. |
markForCheck() vs detectChanges()markForCheck() says “include me in the next normal pass.” detectChanges() says “run 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.
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();
}
}
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.
Profiling question | Check first | Why |
|---|---|---|
Why is this list rerendering 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. |
Defaultfavors simplicity;OnPushfavors predictable performance in large trees.- OnPush still updates on events,
asyncpipe emissions, and manual CDR APIs, but it will skip deep mutation on the same input reference. markForCheck()is the normal manual escape hatch;detectChanges()is the sharper synchronous tool.- The highest-signal production bugs are stale OnPush UIs from mutation, manual subscription updates without marking, 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.