Skip to content
Angular ng performance 5 min read

OnPush & Signals for Performance

Angular’s default change detection re-checks every component in the tree on every event, timer, or HTTP callback. For most apps that’s fast enough, but in large or data-heavy UIs it becomes the dominant cost. OnPush change detection and signals let you tell Angular exactly which components depend on which state, so it skips everything that hasn’t actually changed. Together they turn change detection from a whole-tree sweep into a targeted update.

How default change detection works

By default Angular uses the Default change detection strategy. Whenever a “zone event” fires — a click, a setTimeout, an XHR completion — Angular runs change detection over the entire component tree, dirty-checking every template binding to see if its value changed. The result is correct, but the work scales with the size of your tree, not the size of the change.

Consider a dashboard with hundreds of rows. Clicking a single button anywhere on the page forces Angular to re-evaluate every binding in every row, even though none of that data moved.

Switching a component to OnPush

ChangeDetectionStrategy.OnPush tells Angular to skip a component (and its subtree) during change detection unless something it actually depends on changed. With OnPush, a component is only re-checked when:

  • One of its @Input() references changes (a new object, not a mutated one).
  • An event handler inside its template fires.
  • An observable bound with the async pipe emits.
  • A signal read in its template produces a new value.
  • You manually call ChangeDetectorRef.markForCheck().
import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article class="card">
      <h3>{{ user().name }}</h3>
      <p>{{ user().email }}</p>
    </article>
  `,
})
export class UserCardComponent {
  // Signal-based input — new in Angular 17.2+
  user = input.required<{ name: string; email: string }>();
}

Because this card is OnPush, Angular ignores it during unrelated change detection cycles. It only re-renders when the user input reference changes.

Warning: OnPush relies on immutability. If a parent mutates an object in place (user.name = 'New') instead of replacing the reference, the input identity stays the same and the OnPush child will not update. Always create a new object/array reference when state changes.

Signals: fine-grained reactivity

Signals are reactive values that track exactly who reads them. When a signal changes, Angular knows precisely which template bindings and which computed values depend on it — there’s no tree walking involved. This is what makes signals and OnPush such a natural pair.

import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';

@Component({
  selector: 'app-cart',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ul>
      @for (item of items(); track item.id) {
        <li>{{ item.name }} — {{ item.price | currency }}</li>
      }
    </ul>
    <p>Items: {{ count() }}</p>
    <p>Total: {{ total() | currency }}</p>
    <button (click)="add()">Add sample item</button>
  `,
})
export class CartComponent {
  items = signal<{ id: number; name: string; price: number }[]>([]);

  // computed values recalculate only when their signal dependencies change
  count = computed(() => this.items().length);
  total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));

  add(): void {
    // Replace the reference so OnPush + signals both pick up the change
    this.items.update((list) => [
      ...list,
      { id: list.length + 1, name: `Item ${list.length + 1}`, price: 9.99 },
    ]);
  }
}

When add() runs, only the bindings that read items, count, and total are refreshed. Sibling components — even other OnPush components in the same view — are untouched.

Output:

Items: 1
Total: $9.99
Items: 2
Total: $19.98

Why the combination minimizes work

With default change detection a state change triggers a full pass. With OnPush, Angular prunes whole subtrees. With signals, Angular narrows further still — down to individual bindings. The table below shows the progression.

ApproachWhat gets checked on a changeTracking granularity
DefaultEntire component treeNone — re-checks everything
OnPushOnly components with changed inputs/eventsPer component subtree
OnPush + signalsOnly bindings that read the changed signalPer binding

In a future Angular release, zoneless change detection makes signals the primary trigger and removes zone.js entirely. Writing OnPush + signal code today positions your components to run zoneless with no changes.

Migrating an existing component

You can adopt this incrementally. Convert a property to a signal, switch the class fields, then flip the strategy to OnPush.

// Before
export class CounterComponent {
  count = 0;
  increment() { this.count++; }
}

// After
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<button (click)="increment()">Count: {{ count() }}</button>`,
})
export class CounterComponent {
  count = signal(0);
  increment(): void {
    this.count.update((n) => n + 1);
  }
}

The click handler keeps the component in the OnPush refresh path, and the signal read tells Angular precisely what to update.

Tip: Use effect() for side effects that react to signals (logging, syncing to localStorage), but keep derived state in computed() — effects don’t participate in rendering and can cause extra work if overused.

Best practices

  • Make OnPush your default strategy for new components, then prove correctness with signals or immutable inputs.
  • Always replace object/array references on update (update/set with a new value) — never mutate in place under OnPush.
  • Prefer computed() for derived values instead of recomputing in templates or lifecycle hooks.
  • Use signal inputs (input()) over decorator @Input() so child components stay reactive without manual markForCheck().
  • Reserve effect() for genuine side effects; don’t use it to set other signals (that’s what computed() is for).
  • Avoid storing large mutable structures directly in signals; model state as small, replaceable slices to keep updates cheap.
  • Write OnPush + signal code now to be ready for zoneless Angular with no rework.
Last updated June 14, 2026
Was this helpful?