OnPush change detection is easiest to explain as a real stale-UI debugging story. Angular skips a component unless a narrow trigger happens, so production bugs usually come from mutating references, updating state inside manual subscriptions, or misunderstanding parent-child boundaries.
Use this Angular interview question to rehearse a quick answer, common mistake, follow-up, and production pitfall.
Explain OnPush Change Detection in Angular Like You’re Debugging a Real Production BugFrontend interview answer
This Angular interview question tests whether you can explain Angular OnPush in production: triggers, stale-UI bugs, and markForCheck debugging, connect it to production trade-offs, and handle common follow-up questions.
- Angular OnPush in production: triggers, stale-UI bugs, and markForCheck debugging explanation without falling back to memorized docs wording
- Change Detection and Onpush reasoning, edge cases, and production failure modes
- How you would answer the most likely Angular interview follow-up
Core ideaChangeDetectionStrategy.OnPush tells Angular: “Only check this component if something meaningful happened.” Meaningful = an @Input reference changed, an event happened in this component, an async pipe emitted, or you manually asked Angular to check.
What happened? | Will OnPush re-render? | Why |
|---|---|---|
An @Input reference changed | ✅ Yes | Angular compares references, not deep values. |
A click/event inside the component | ✅ Yes | Events mark the component dirty. |
An async pipe emits a new value | ✅ Yes | AsyncPipe internally calls markForCheck(). |
You mutated an object/array in place | ❌ No | Reference did not change, Angular thinks nothing happened. |
You call markForCheck() manually | ✅ Yes | You explicitly told Angular to re-check. |
You call detectChanges() manually | ✅ Yes (immediate) | Forces synchronous change detection for this subtree. |
The classic real-world bug
“My data changes but the UI doesn’t update.” This almost always means you mutated an object or array instead of creating a new reference.
@Component({
selector: 'app-user-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div *ngFor="let user of users">{{ user.name }}</div>
`
})
export class UserListComponent {
@Input() users: { name: string }[] = [];
// ❌ BUG: UI will NOT update in OnPush
addUserWrong() {
this.users.push({ name: 'New User' }); // same array reference
}
// ✅ Correct: new reference => OnPush sees change
addUserCorrect() {
this.users = [...this.users, { name: 'New User' }];
}
}
Why OnPush exists
Default change detection checks the whole component tree on every async event (timers, HTTP, clicks, etc). OnPush lets Angular skip huge parts of the tree unless something relevant changed. This is critical for large apps and big lists.
Second real bug variant: manual subscription updates state, but the view still looks stale
Even if no input mutation happens, an OnPush component can still miss updates when you subscribe imperatively and update local state without async pipe or markForCheck(). This is the other classic production bug after in-place mutation.
@Component({
selector: 'app-summary-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ stats.total }}`
})
export class SummaryCardComponent {
stats = { total: 0 };
constructor(
private statsService: StatsService,
private cd: ChangeDetectorRef
) {
this.statsService.total$.subscribe(total => {
// ❌ Bug: same object reference + no explicit mark
this.stats.total = total;
});
}
connectSafely() {
this.statsService.total$.subscribe(total => {
this.stats = { ...this.stats, total };
this.cd.markForCheck();
});
}
}
markForCheck() vs detectChanges()
Method | What it does | When to use |
|---|---|---|
| Marks this component and its parents as dirty for the next CD cycle. | When data changes outside Angular's awareness (e.g., custom subscriptions, timers, non-async-pipe streams). |
| Immediately runs change detection on this component subtree. | Rare cases: manual rendering, detached views, or very controlled performance scenarios. |
AsyncPipe is your best friend
The async pipe automatically:
• Subscribes
• Unsubscribes
• Calls markForCheck() on emission
This is why OnPush + async pipe is the recommended default pattern.
Common senior-level pitfalls
• Mutating arrays/objects instead of replacing them
• Forgetting that OnPush uses reference checks, not deep checks
• Updating state inside subscriptions without async pipe or markForCheck
• Assuming a parent mutating child.input.deep.nestedValue should refresh the child even though the input reference stayed the same
• Using OnPush everywhere without understanding its mental model
Scenario | What to do | Why |
|---|---|---|
State comes from Observables | Use | Automatic CD + auto unsubscribe + best performance. |
State updated manually in code | Use immutable updates (new reference) | So OnPush can detect the change. |
State updated outside Angular or in manual subscriptions | Call | Otherwise Angular will not re-render. |
How to debug it in a real app
Use Angular DevTools or a quick logging pass to verify which component is actually being checked. If the parent rerenders but the child does not, look for an unchanged input reference. If neither rerenders after a subscription callback, check whether the update happened outside the normal template flow and needs markForCheck() or an async pipe.
Interview-level summaryOnPush makes Angular check a component only when inputs change by reference, an event happens, an async pipe emits, or you manually trigger detection. It gives big performance wins, but requires immutable state updates. Most “UI not updating” bugs in OnPush come from mutating objects instead of replacing them.
Use this as one explanation rep, then continue with the Angular interview questions cluster or a guided prep path.