Key Takeaways
- Angular change detection separates scheduling an update from traversing views, evaluating bindings, and updating the DOM.
- OnPush does not mean “inputs only.” Angular-handled events, explicit marking, async-pipe emissions, and template-read signals also make views eligible for checking.
- Mutating an input object without replacing its reference can leave an OnPush child stale. Immutable updates make changes explicit; immutability is not a framework requirement.
- Understand your application's scheduling configuration and component strategies before optimizing. Fewer checks aren't automatically a meaningful performance improvement.
Angular change detection keeps component views synchronized with application state by evaluating template bindings and updating affected DOM bindings when their values change. The important questions are what tells Angular to schedule work and which views it checks afterward.
How Angular Change Detection Works
A binding connects application data to a template expression, such as {{ name }}. When Angular checks that view, it evaluates the expression, compares the result with the stored previous value, and updates the corresponding DOM binding if needed. Angular's binding-check implementation performs runtime comparisons; compiling a template does not eliminate them.
Think in terms of component views rather than a recursive scan of DOM nodes. A view is Angular's representation of a rendered template, including its bindings and child views.
Differences from AngularJS
AngularJS used a digest cycle to evaluate watched expressions against their previous values. Watcher listeners could change other watched values, so the digest repeated until the model stabilized. It was dirty checking of expressions, not repeated inspection or replacement of the entire DOM.
Both frameworks update affected bindings rather than blindly rewriting the whole page. The useful distinction is AngularJS's watcher-based digest versus Angular's compiled component-view checks. The AngularJS scope guide explains the original mechanism; our guide to AngularJS watchers provides additional historical context.
Unidirectional Data Flow Is Not a State Architecture
Angular generally traverses the application tree from parent views toward child views. That gives input bindings a predictable direction during checking, but it does not make your application's state management Flux-style or forbid two-way binding.
The practical rule is to avoid changing already-checked state halfway through a traversal. Angular's component lifecycle guide describes how lifecycle hooks fit into that traversal.
AOT and JIT Are Separate from Runtime Checking
Angular compiles templates into executable JavaScript. Ahead-of-time compilation, or AOT, does this during the build; just-in-time compilation, or JIT, does it at runtime in the browser. AOT is the default for new CLI applications.
Neither mode removes the need to evaluate changing bindings at runtime. Use the official compilation guide for build behavior, and our Angular AOT versus JIT comparison for the broader tradeoffs.
Angular Change Events: Scheduling Versus Checking
A notification tells Angular that rendering work may be necessary. Scheduling that work is separate from deciding which attached views to refresh. A notification does not imply that every component's template will be evaluated.
ZoneJS task hint OR Angular state notification
|
v
Scheduled synchronization
|
v
Traverse attached views
|
+------------+-------------+
| |
Eager: check when reached OnPush: check when eligible
in a global traversal through an update trigger
| |
+------------+-------------+
|
v
Evaluate bindings and compare previous values
|
v
Update affected DOM bindingsTraditional ZoneJS-Based Scheduling
In a ZoneJS-based application, ZoneJS patches browser APIs and captures asynchronous activity such as event listeners, timers, and network requests. Angular uses that activity as a hint that application state might have changed.
Those hints aren't proof of a state change. A mouseover listener or setInterval() callback can cause unnecessary synchronization even when it doesn't affect rendered data. Angular calls this zone pollution.
Current Zoneless Change Detection
Zoneless change detection is the default in Angular 21 and later unless the application overrides it with provideZoneChangeDetection(). It relies on Angular notifications instead of ZoneJS's interception of asynchronous tasks.
For example, updating a signal read by a template, invoking an Angular-bound listener, calling ChangeDetectorRef.markForCheck(), or setting an input through ComponentRef.setInput() can notify Angular. These mechanisms are documented by provideZonelessChangeDetection.
A timer, HTTP completion, or manually subscribed Observable does not by itself make an ordinary field assignment a zoneless notification. Connect the resulting state to a template-read signal, the async pipe, or explicit marking. The zoneless migration guide also explains how to remove ZoneJS from application and test builds.
Angular OnPush Change Detection
OnPush lets Angular skip component subtrees that have no relevant update to process. It is a checking strategy, not a promise that Angular watches only input assignments.
The defaults have changed: OnPush is the default strategy in Angular 22. The eager strategy is now named ChangeDetectionStrategy.Eager; ChangeDetectionStrategy.Default is a deprecated alias for it. The strategy API reference documents the current names.
| Strategy | Checking behavior | Practical implication |
|---|---|---|
OnPush | Checks when relevant inputs, events, reactive dependencies, or explicit notifications require it. | Use explicit update paths; unchanged subtrees can be skipped. |
Eager | Checks eagerly when a global change-detection traversal reaches the view. | Does not make an otherwise unnotified zoneless field assignment schedule rendering. |
What Makes an OnPush Component Eligible for Checking?
Common update paths include:
- A changed template-bound input: Angular compares the previous and current input values. For an object, a new reference makes the input different.
- An Angular-handled event in its subtree: Template events, output bindings, and host listeners can cause checking. An event in a descendant also makes its OnPush ancestors eligible.
- Explicit marking:
markForCheck()marks the view for a later check. - An async-pipe emission:
AsyncPipemarks its consuming component when a new value arrives. - A template-read signal changing: Angular tracks that dependency and marks the consuming component for updating.
An event in one branch doesn't require checking unrelated, unchanged OnPush subtrees. Angular's subtree-skipping guide illustrates these boundaries. For Observable-backed templates, AsyncPipe also handles subscription cleanup when the component is destroyed.
Immutability and OnPush: A Reproducible Example
In an Angular 22 browser application, replace src/main.ts with the following example. Ensure src/index.html contains <app-root></app-root>. The child uses a required signal input; OnPush is specified explicitly to make the boundary visible.
import {
ChangeDetectionStrategy,
Component,
input,
signal,
} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
interface Person {
name: string;
}
@Component({
selector: 'app-person-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Child: {{ person().name }}</p>
<button type="button" (click)="like()">
Like: {{ likes() }}
</button>
`,
})
class PersonCard {
readonly person = input.required<Person>();
readonly likes = signal(0);
like(): void {
this.likes.update(value => value + 1);
}
}
@Component({
selector: 'app-root',
standalone: true,
imports: [PersonCard],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Parent: {{ person.name }}</p>
<button type="button" (click)="mutate()">Mutate</button>
<button type="button" (click)="replace()">Replace</button>
<button type="button" (click)="reset()">Reset</button>
<app-person-card [person]="person" />
`,
})
class DemoApp {
person: Person = { name: 'Ada' };
mutate(): void {
this.person.name = 'Sam';
}
replace(): void {
this.person = { ...this.person, name: 'Sam' };
}
reset(): void {
this.person = { name: 'Ada' };
}
}
bootstrapApplication(DemoApp).catch(error => console.error(error));- Initially, both views display
Ada. - Click Mutate. The parent displays
Sam, but the child remainsAda: the parent's event schedules checking, but the child's input reference hasn't changed. - Click Like inside the child. The child now displays
Sambecause its own event makes it eligible for checking. - Click Reset, then Replace. Both views display
Sambecause the new object crosses the input boundary.
Mutation isn't invisible forever, and OnPush doesn't require frozen objects. The failure is relying on a same-reference mutation to notify a child through its input. Replacing changed objects makes that contract explicit without requiring an immutability library.
For input-driven lifecycle behavior, see our ngOnChanges example.
Signals and Asynchronous Updates
A signal wraps a value and notifies its consumers when that value changes. Reading it in a template establishes a dependency; merely declaring a signal doesn't mean every component depends on it.
For an independent asynchronous experiment, replace src/main.ts with this example, keeping the same root element:
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
signal,
} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>Timer callbacks: {{ count() }}</p>`,
})
class TimerDemo {
readonly count = signal(0);
constructor() {
const timer = setInterval(() => {
this.count.update(value => value + 1);
}, 1000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
}
}
bootstrapApplication(TimerDemo).catch(error => console.error(error));The signal update supplies the notification even in a zoneless application; the timer itself isn't the notification. The destruction callback clears the interval when the component is destroyed.
Signals don't automatically observe nested object mutations. By default, signal equality uses Object.is(), so prefer returning a new object from update() when changing object state. The signals guide explains template dependencies and equality.
Manual Change Detection in Angular
Setting an OnPush strategy is not the same as manually running a check. ChangeDetectorRef provides explicit controls when normal input, event, signal, or async-pipe notifications aren't the right integration point.
| Method | Effect | Use it when |
|---|---|---|
markForCheck() | Marks the view for a later check; it does not synchronously update the DOM. | An external callback changes ordinary component fields. |
detectChanges() | Immediately checks this view and its eligible descendants. | You deliberately need a local synchronous check. |
detach() | Removes the view from normal change-detection traversal, even if it is marked dirty. | You take ownership of its refresh schedule. |
reattach() | Restores the detached view to normal traversal. | You return refresh scheduling to Angular. |
import { ChangeDetectorRef, inject } from '@angular/core';
// Fragment: merge this class's members into an existing component class.
class ExampleComponent {
private readonly cdr = inject(ChangeDetectorRef);
message = '';
receiveExternal(value: string): void {
this.message = value;
this.cdr.markForCheck(); // Let Angular schedule the refresh.
}
refreshImmediately(value: string): void {
this.message = value;
this.cdr.detectChanges(); // Perform a local check now.
}
}Use one method according to the integration's timing requirements, not both by habit. Detachment is a specialized optimization: ordinary notifications won't refresh a detached view through normal traversal, although you can still check it explicitly with detectChanges().
Common Mistakes and Performance Checks
- Assuming OnPush guarantees speed: Its value depends on how much unnecessary work it actually skips. Profile the interaction before and after changing strategy.
- Ignoring binding cost: An expensive template expression or lifecycle hook remains expensive whenever its view is checked. Angular's slow-computation guidance recommends fixing the underlying algorithm and considering caching.
- Confusing subscription with notification: Assigning a plain field in a manual subscription is not equivalent to consuming the stream with the async pipe.
- Using forced checks to hide missing notifications: Test the actual event, signal, or external-callback update path rather than adding manual checks after every state write.
Use the Angular DevTools profiler to record a slow interaction, inspect which components ran, and examine time spent in templates and lifecycle hooks. Optimize the measured bottleneck, not the number of OnPush declarations.
Conclusion
Understand the normal behavior before taking manual control. In current Angular, that includes OnPush and zoneless defaults—not just the older eager, ZoneJS-based model. Start with clear input boundaries and explicit state notifications, then use profiling to decide whether more control is worth the complexity.
FAQ
How does Angular change detection work?
Angular schedules synchronization after relevant notifications, traverses eligible attached views, and evaluates their template bindings. It compares binding values with previous values and updates affected DOM bindings when they change.
What triggers change detection in an OnPush component?
Common triggers include changed template-bound inputs, Angular-handled events in the component subtree, markForCheck(), async-pipe emissions, and changes to signals read by the template. Unrelated, unchanged OnPush subtrees can still be skipped.
Does OnPush require immutable objects?
No. A mutation can appear when another trigger causes the view to be checked, but retaining the same object reference does not notify a child through that input. Replacing changed objects makes input-driven updates predictable.
Can Angular run without Zone.js?
Yes. Zoneless change detection is the default in Angular 21 and later unless overridden. It uses Angular notifications, such as template-read signal changes and markForCheck(), rather than relying on ZoneJS to intercept asynchronous activity.
When should I use markForCheck instead of detectChanges?
Use markForCheck() when a view should participate in a later scheduled check. Use detectChanges() when you deliberately need an immediate local check, including manually managed detached views; ordinary components should usually rely on normal Angular notifications.
Further Reading
- Skipping component subtrees — OnPush inputs, events, and traversal boundaries.
- Angular without ZoneJS — scheduling notifications, migration, and testing.
- Angular Signals — template dependencies, updates, and equality.
- ChangeDetectionStrategy reference — current strategy names and defaults.
- ChangeDetectorRef reference — marking, local checks, detachment, and reattachment.
- AsyncPipe reference — asynchronous values and automatic marking.
- Angular DevTools profiler — recording and inspecting change-detection work.
- AngularJS scopes — historical watcher evaluation and digest behavior.
