Angular OnPush Deep Dive: How Change Detection Really Walks the Tree [2026]

Link copied
Angular OnPush Deep Dive: How Change Detection Really Walks the Tree [2026]

Angular OnPush Deep Dive: How Change Detection Really Walks the Tree [2026]

Knowing that OnPush components only update "when something marks them dirty" is enough to avoid the common bugs. It isn't enough to explain the strange ones: the child that updates even though its parent is OnPush, the ngDoCheck that runs on a component Angular just decided to skip, or the list that re-renders more than you expected after switching to signals. Those come down to how marking actually travels through the component tree.

This is lesson 8.2 of the Angular Tutorial. Lesson 8.1 covered what changed in Angular 22 and the everyday rules. This lesson goes under the hood: how change detection walks the tree, what markForCheck() and signals each mark, where OnPush wins big, where it bites, and how to measure the difference instead of guessing.

How a change detection pass walks the tree #

Every pass starts at the root and walks down. At each component Angular asks one question: does this view need checking?

Component state What happens
Eager Always checked when the walk reaches it
OnPush, dirty Checked, and its children are visited
OnPush, clean Skipped entirely, along with its whole subtree
OnPush, clean but has a dirty descendant Not re-checked itself, but the walk continues down to reach the descendant

That last row is newer than most OnPush explanations you'll find, and it matters for signals. More on it below.

The key consequence of the third row: a clean OnPush component hides everything below it. An Eager child inside a skipped OnPush parent is not checked either, because the walk never reaches it. Eager means "check me whenever you get here", not "always check me".

What markForCheck() marks #

ChangeDetectorRef.markForCheck(), which the async pipe calls for you on every emission, marks the current component dirty and every ancestor up to the root. On the next pass, the walk re-checks each of those ancestors' templates on the way down.

export class Ticker {
  private cdr = inject(ChangeDetectorRef);
  protected price = 0;

  constructor() {
    inject(PriceFeed).prices$
      .pipe(takeUntilDestroyed())
      .subscribe(p => { this.price = p; this.cdr.markForCheck(); });
  }
}

That's correct, but not free: a price update deep in a dashboard re-checks the dashboard, its layout and the app shell, just to reach one number.

What signals mark: targeted refresh #

A signal read in a template works differently. When the signal changes, Angular marks only the component that read it for refresh, and marks its ancestors merely as on the path: they're traversed so the walk can reach the component, but their own templates aren't re-evaluated. This targeted behaviour arrived with Angular's signal integration (Angular 17) and is why signals and OnPush pair so well.

export class Ticker {
  protected price = toSignal(inject(PriceFeed).prices$, { initialValue: 0 });
}

Same feature, less work: the dashboard, layout and shell are walked through but not re-rendered. On a page with a few frequently updating widgets, the difference shows up clearly in the profiler.

Marking method Component Ancestors
markForCheck() / async pipe Re-checked Re-checked
Signal read in template changes Re-checked Traversed only
Input receives a new reference Re-checked Parent was already being checked
Event handled in the component Re-checked Re-checked (events mark the path dirty)

Why ngDoCheck runs on skipped components #

ngDoCheck is called on a child when its parent is checked, before Angular decides whether the child itself needs checking. So an OnPush component whose parent is Eager gets ngDoCheck on every pass, even when its template is skipped.

That's by design: ngDoCheck is the hook for custom dirty checking, such as comparing a mutable input by hand and calling markForCheck() if it changed. But it means expensive work in ngDoCheck defeats OnPush. Keep it tiny, or replace the whole pattern with signals and computed(), which make custom dirty checking unnecessary.

Content projection follows where the template is written #

Projected content is checked as part of the component that declares it, not the component that renders it:

<!-- parent.html -->
<app-card>                      <!-- OnPush, clean -->
  <p>{{ message() }}</p>        <!-- belongs to Parent's view -->
</app-card>

When message() changes, the <p> updates even though Card is OnPush and clean, because that paragraph is part of Parent's template. This surprises people in both directions. An OnPush wrapper doesn't freeze projected content, and a wrapper's own state doesn't make its projected content re-render. When projected content doesn't update, look at the declaring component, not the wrapper.

Where OnPush wins big #

  • Long lists and tables. Each row as an OnPush component with a stable track (lesson 8.3) means a change to one row checks one row.
  • Dashboards with independent widgets. Signals per widget refresh only the widget that changed.
  • Frequently updating data such as prices, presence indicators and progress bars, especially near the leaves of the tree.
  • Heavy templates. Components with many bindings or template function calls stop paying for unrelated changes elsewhere on the page.

Where it bites #

  • Mutation. Covered in 8.1: same reference, no update.
  • Imperative writes. Setting a child's input through @ViewChild bypasses input change detection.
  • Eager islands. An Eager component placed under an OnPush parent looks as if it's working until the parent stops being marked, and then it freezes too.
  • Libraries that mutate. Some older component libraries expect to be checked eagerly. Wrap them in a component that converts their callbacks into signal writes.
  • Template function calls hiding cost. {{ total() }} is cheap when total is a computed; {{ calculateTotal() }} as a plain method runs on every check of that component. OnPush reduces how often it runs, but computed() removes the problem.

Beyond OnPush: detaching a view #

For the rare component that updates many times a second, such as a live chart, a drag preview or a game loop, even targeted refresh can be more than you want. ChangeDetectorRef lets you take a component out of automatic change detection completely and render it on your own schedule:

export class LiveChart {
  private cdr = inject(ChangeDetectorRef);
  protected points: Point[] = [];

  constructor() {
    this.cdr.detach();                          // never checked automatically
    const feed = inject(TelemetryFeed);
    let frame = 0;
    const loop = () => {
      this.points = feed.latest();              // many updates between frames…
      this.cdr.detectChanges();                 // …but render at most once per frame
      frame = requestAnimationFrame(loop);
    };
    frame = requestAnimationFrame(loop);
    inject(DestroyRef).onDestroy(() => cancelAnimationFrame(frame));
  }
}

detectChanges() checks this component and its children immediately, without involving the rest of the tree. It's a sharp tool, so reach for it only after the profiler shows OnPush and signals aren't enough:

  • A detached component ignores inputs, events and signals until you call detectChanges() or reattach().
  • Forgetting reattach() or the render call is a silent freeze, which is harder to spot than an OnPush bug.
  • For most "updates too often" problems, throttling the source (a debounced signal, or auditTime in RxJS) is simpler and keeps the component in the normal model.

Measure, don't guess #

Angular DevTools (the browser extension) has a Profiler tab that records change detection passes:

  1. Open DevTools → Angular → Profiler, and start recording.
  2. Perform the interaction you care about: typing in a search box, a price tick, opening a panel.
  3. Stop and inspect each pass: which components were checked and how long each took.

Look for two things: components that are checked but didn't need to be (usually Eager components, or ancestors re-checked by markForCheck()), and passes triggered by events you didn't expect, such as scroll or mouse-move listeners. Change one thing at a time and profile again. For a quick check without the extension, a temporary counter shows how often a template is evaluated:

private renders = 0;
protected tick() { return ++this.renders; }   // bind {{ tick() }} in the template, remove afterwards

Gotchas #

Symptom Cause Fix
Eager child stopped updating Its OnPush parent is clean, so the walk never reaches it Make the child signal-driven, or mark the parent correctly
ngDoCheck shows up in the profiler Called on every parent check, even for skipped OnPush components Move logic to computed() / signals; keep ngDoCheck trivial
Projected content updates "through" an OnPush wrapper Projected nodes belong to the declaring component Expected; debug the declaring component
Switching async pipe → signals made the page faster markForCheck() re-checks ancestors; signals only traverse them Prefer signals for frequently updating values
Page still slow after going all-OnPush One Eager component or a costly template near the root Profile; convert that component first
Input changes ignored after @ViewChild write Imperative writes aren't input bindings Bind in the template, or markForCheck() on the child

What's next #

Lesson 8.3 tackles the other big source of wasted rendering: @for loops whose track expression makes Angular destroy and recreate DOM on every change, and the one-line fix. Later in this module, 8.8 pulls everything together into a playbook for avoiding unnecessary re-renders.

Try it yourself #

Build a small dashboard with three widgets: one fed by markForCheck() inside a subscribe, one by the async pipe, and one by toSignal(), all updating once a second. Record a Profiler session and compare which components are checked on each tick. Then wrap one widget in an OnPush card and confirm that projected content inside it still updates.

YouMy whole app is OnPush now, but the profiler shows the layout and shell components re-checked every time the notification badge updates. Why?
Claude · used search_documentationThe badge is fed by notifications$ | async. The async pipe calls markForCheck() on each emission, and that marks the badge and every ancestor dirty, so the layout and shell templates are re-checked on the way down. Switch the badge to a signal, count = toSignal(notifications$, { initialValue: 0 }), and bind {{ count() }}. A signal change marks only the component that reads it for refresh; its ancestors are traversed but not re-checked. Profile again afterwards: the shell and layout should drop out of the list of checked components on each update.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *