Angular Change Detection Performance: OnPush and Signals

If you're building an Angular front end that talks to an ASP.NET Core Web API, you've probably hit this wall: the API responds in 40ms, the network tab looks fine, and yet the UI still feels sluggish whenever data updates. Often, that's not a backend problem. It's Angular checking far more of your component tree than it needs to, on every click, every HTTP response, every setTimeout, and every scroll event in apps that use Zone.js.

The short answer: to improve Angular change detection performance, use the OnPush strategy so Angular skips components whose inputs haven't changed, and use Signals so Angular knows exactly which component's template depends on which piece of state. Measure first with Angular DevTools, then fix the worst offenders.

This article walks through why slowdowns happen, how to measure them instead of guessing, and how to fix them using OnPush and Angular Signals — a reactive primitive (a value container that notifies consumers when it changes) that gives Angular a more precise, dependency-tracked alternative to its older dirty-checking model. We'll also cover the migration path from an existing OnPush + RxJS Observable codebase to Signals, because that's the situation most production Angular apps are actually in, and it's the part most tutorials skip.

Angular Component Tree Performance Infographic

A quick note on versions, because Angular has changed quickly. signal() and computed() were stable by Angular 17. Signal-based inputs such as input() arrived in Angular 17.1 and became stable in Angular 19. effect(), toSignal(), and linkedSignal() became stable in Angular 20, so on Angular 17–19 they are developer preview APIs. Zoneless change detection became stable in Angular 20.2 and is the default for new apps in Angular 21. In Angular 22 (released June 2026), OnPush became the default strategy and the old Default strategy was renamed Eager. The examples below set changeDetection explicitly where it matters, so they behave the same on every version. Still, double-check the official Angular documentation for your installed version before shipping.

Prerequisites

  • Basic familiarity with Angular components, templates, and TypeScript.
  • An Angular 17+ project. Examples use standalone components and example file paths, so adjust imports to match your project.
  • An Order interface with id, customerName, and total properties, which the examples assume you already have.
  • HttpClient configured (for example with provideHttpClient()) if you want to try the RxJS example.
  • The Angular DevTools browser extension. It only works with development builds (for example ng serve), not production builds.

Why Angular Change Detection Can Slow Down Your UI

In apps that use Zone.js, Angular's classic change detection runs what's often called "dirty checking" across the entire component tree whenever Zone.js notices an async event. Zone.js is a library that patches async browser APIs like setTimeout, addEventListener, and XMLHttpRequest so Angular knows when something might have changed. That includes HTTP responses, DOM events, timers — basically anything async. Without extra hints, Angular doesn't know which components depend on the data that changed, so it walks the whole tree and checks every binding on every component.

For a small app, this is invisible. For a dashboard with grids, charts, and nested components bound to frequently updating API data — for example an ASP.NET Core Web API returning paginated order data or live metrics — this adds up fast. Each HTTP poll or SignalR push (SignalR is Microsoft's real-time messaging library for ASP.NET Core) can trigger a full tree traversal even if most of the components on screen have nothing to do with the data that just arrived.

The fix isn't "don't use Angular's reactivity," it's "tell Angular which parts of the tree it can skip." That's exactly what OnPush and Signals do, in complementary ways. OnPush narrows when a component is checked. Signals narrow what gets checked by tracking which components actually read which state, instead of relying on tree-wide dirty checking.

If your app is already zoneless (the default for new apps from Angular 21), Angular doesn't check everything after every async event. It runs change detection only when notified. The techniques below still apply, because zoneless controls when Angular schedules change detection, while OnPush and Signals control which components take part. The "every async event triggers a check" behavior described above is specific to Zone.js apps.

Angular Change Detection Performance: Profile Before You Optimize

Don't optimize based on a hunch. Angular DevTools (the official browser extension for Angular) includes a profiler that records change detection cycles and shows you, component by component, how long each check took during a recorded interaction.

To use it: open your app (running in development mode) in a browser with Angular DevTools installed, switch to the Profiler tab, click record, interact with the part of the UI that feels slow — say, typing in a search box bound to a large list — then stop recording. You'll see a bar chart where each bar is one change detection cycle, and taller bars mean more time spent. Select a bar to see which components were checked and how long each took, and what triggered that cycle. You can switch to a flame graph view to see the component hierarchy for that cycle.

What you're looking for is components that get checked repeatedly despite their inputs never changing. A ProductCardComponent that is checked fifty times while you type in an unrelated search field is a textbook sign that it's running under the eager (formerly default) strategy and has no reason to be in the hot path.

Chrome DevTools' Performance tab is a useful second opinion, and Angular can add its own change detection entries to that panel in development mode. Record a trace during the same interaction and look at the "Scripting" time attributed to Angular's internals — long or frequent ApplicationRef.tick calls (Angular's internal method that triggers a change detection pass across the app) are a strong signal that cycles are running more often than they should. Because development builds do extra checking, treat all timings as a before/after comparison on your own app rather than a number to cite externally.

A practical baseline worth capturing before you touch any code: record a 10-second interaction profile, note the total number of change detection cycles and the average cycle duration, and save that recording. After each optimization pass, re-run the same interaction and compare. This is the benchmarking methodology we'll come back to later in this article.

Optimization Techniques

Understanding the Default (Eager) Strategy and Where It Breaks Down

Through Angular 21, every component that doesn't set a strategy uses ChangeDetectionStrategy.Default, meaning it gets checked on every change detection cycle regardless of whether its inputs changed. In Angular 22 this strategy was renamed ChangeDetectionStrategy.Eager, and ng update adds Eager to existing components so their behavior doesn't change. It is safe — nothing gets missed — but it's expensive at scale because the cost scales with the size of your component tree, not with how much actually changed.

typescript
import { Component, Input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';

@Component({
  selector: 'app-order-summary',
  standalone: true,
  imports: [CurrencyPipe],
  template: `
    <div class="order-card">
      <h3>{{ order.customerName }}</h3>
      <p>Total: {{ order.total | currency }}</p>
    </div>
  `
})
export class OrderSummaryComponent {
  @Input() order!: Order;
}

This component looks harmless, but in Angular 21 and earlier (or with Eager set explicitly) it gets re-checked every single time anything in the app triggers change detection, not just when order changes. In a list of 200 orders, that's 200 unnecessary checks per unrelated event somewhere else on the page.

Applying OnPush Strategy Correctly

OnPush tells Angular to skip checking a component unless something relevant to it happens. The main triggers are: an @Input() reference changes, an event originates from the component or one of its children (a click handler, for example), a signal read in the template changes, an async pipe in the template emits a new value, or you manually flag the component by calling ChangeDetectorRef.markForCheck() (a method that marks a component as needing to be checked on the next cycle).

typescript
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';

@Component({
  selector: 'app-order-summary',
  standalone: true,
  imports: [CurrencyPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="order-card">
      <h3>{{ order.customerName }}</h3>
      <p>Total: {{ order.total | currency }}</p>
    </div>
  `
})
export class OrderSummaryComponent {
  @Input() order!: Order;
}

The catch — and this is the part that silently defeats OnPush in production codebases more often than anything else — is that OnPush checks for input changes by reference, not by deep equality. If your parent component mutates an object in place and passes the same reference back down, OnPush won't see a change and the child won't update, even though the data is genuinely different.

typescript
// This defeats OnPush — same object reference, mutated in place
updateOrderTotal(order: Order, newTotal: number): void {
  order.total = newTotal; // mutation, reference unchanged
}

// This works with OnPush — returns a new object that the parent must store
withUpdatedTotal(order: Order, newTotal: number): Order {
  return { ...order, total: newTotal }; // new object reference
}

The spread syntax (...order) copies every property of the original object into a brand-new object, and then total is overwritten. The parent must then store the returned object (for example, by replacing that item in its array) so the child receives a new reference. This is the immutability pattern OnPush depends on: always produce a new object or array reference when data changes, never mutate in place. It's a discipline shift more than a technical hurdle, but it trips up almost every team migrating an existing codebase to OnPush for the first time.

Two more common anti-patterns worth calling out explicitly. First, calling a method directly in a template — {{ calculateTotal(order) }} — runs every time the component is checked, because Angular has no way to know if the method's return value changed without calling it. Use a computed() signal or a pre-calculated property instead. Second, binding a child input to a getter or method that builds a new array or object each time, like get activeOrders() { return this.orders.filter(o => o.active); }, hands the child a new reference on every parent check. The child is then updated every time even though the data never actually changed. Compute the value once with computed(), or store it in a property that only changes when the data does.

Introducing Signals as a Reactive Primitive

A Signal is a wrapper around a value that notifies Angular precisely when that value changes. When a template reads a signal, Angular records that dependency, so it knows which components depend on which signals. This lets Angular skip the "did anything change" guessing game for signal-driven state: when a signal changes, only the components that read it are marked for refresh.

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

@Component({
  selector: 'app-cart-summary',
  standalone: true,
  imports: [CurrencyPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Items: {{ itemCount() }}</p>
    <p>Total: {{ total() | currency }}</p>
    <button (click)="addItem(9.99)">Add $9.99 item</button>
  `
})
export class CartSummaryComponent {
  private prices = signal<number[]>([]);

  itemCount = computed(() => this.prices().length);
  total = computed(() => this.prices().reduce((sum, p) => sum + p, 0));

  addItem(price: number): void {
    this.prices.update(current => [...current, price]);
  }
}

Expected result: the page starts with "Items: 0" and "Total: $0.00". Each click on the button increases the item count by one and the total by $9.99.

A few things are worth noting here. signal() creates a readable, writable value — you read it by calling it as a function, prices(), and update it with .set() or .update(). computed() creates a derived value that automatically recalculates only when one of its dependencies changes, and — importantly — it memoizes (caches) its result, so reading total() twice without prices changing doesn't re-run the calculation. Because the template reads itemCount() and total(), Angular knows this component depends on them and refreshes it when they change, without a tree-wide check.

This pairs naturally with OnPush: even on an OnPush component, a change to a signal read in the template marks that component for refresh, so you get precise updates without manually calling markForCheck() every time state changes.

Signal-Based Inputs

Signal-based inputs are an alternative to the traditional @Input() decorator, using the input() function. They arrived in Angular 17.1 and became stable in Angular 19. This matters for performance because signal inputs integrate directly into the dependency-tracking graph, so you can derive values from them with computed() instead of reacting to changes by hand.

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

@Component({
  selector: 'app-product-badge',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <span class="badge" [class.low-stock]="isLowStock()">
      {{ stockLevel() }} in stock
    </span>
  `
})
export class ProductBadgeComponent {
  stockLevel = input.required<number>();
  isLowStock = computed(() => this.stockLevel() < 10);
}

input.required<number>() declares a required input as a readable signal. A parent uses it like any other input, for example <app-product-badge [stockLevel]="product.stock" />. If product.stock is 5, the badge shows "5 in stock" and gets the low-stock class. Once inside the component, stockLevel behaves as a first-class signal you can feed into computed() without any extra wiring. Note that signal inputs don't trigger ngOnChanges, so derive values with computed() instead. This removes a category of bugs where developers forget to call markForCheck() after manually reacting to an @Input() change in ngOnChanges.

Avoiding RxJS Interop Pitfalls

Most production Angular apps consuming an ASP.NET Core Web API already lean heavily on RxJS Observables — HttpClient returns Observables, and most state management libraries (NgRx among them) are built around the Observable stream model. You do not need to rip all of that out to benefit from Signals. Angular provides toSignal() for bridging an Observable into a signal, and toObservable() for going the other direction. Both became stable in Angular 20.

typescript
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { EMPTY, catchError, map, switchMap, timer } from 'rxjs';

@Component({
  selector: 'app-live-order-count',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>Open orders: {{ orderCount() }}</p>`
})
export class LiveOrderCountComponent {
  private http = inject(HttpClient);

  private orderCount$ = timer(0, 15000).pipe(
    switchMap(() =>
      this.http.get<{ count: number }>('/api/orders/open-count').pipe(
        map(response => response.count),
        catchError(() => EMPTY)
      )
    )
  );

  orderCount = toSignal(this.orderCount$, { initialValue: 0 });
}

Expected result: the template shows "Open orders: 0" until the first response arrives, then shows the real count and refreshes it every 15 seconds. timer(0, 15000) fires immediately and then every 15 seconds, whereas interval(15000) would wait 15 seconds before the first request. Placing catchError inside switchMap keeps the polling alive after a failed request, and the signal simply keeps its last value. In a real app, log the error or show a message instead of swallowing it silently. Without error handling, an error in the Observable would surface when the signal is read.

toSignal() subscribes to the Observable internally and exposes its latest value as a signal, handling unsubscription automatically when the component is destroyed — you don't need a manual takeUntilDestroyed() call in this specific pattern. The initialValue option avoids the signal being undefined before the first emission, which keeps the template type-safe without needing an | async pipe or an *ngIf guard.

One genuine pitfall: toSignal() must be called within an injection context — the places where Angular's dependency injection is active, such as a constructor, a field initializer, or a function passed to runInInjectionContext(). Calling it inside a method body that runs later, like a click handler, will throw at runtime unless you pass an injector option. This catches people migrating incrementally, because the fix often means restructuring where the Observable-to-signal conversion happens rather than just swapping the API call in place.

Effect() and Its Pitfalls

effect() runs a side-effect function automatically whenever any signal it reads changes. It's useful for things like syncing a signal's value to localStorage or logging, but it's frequently misused as a replacement for computed(), which causes real performance problems.

typescript
import { Component, signal, effect } from '@angular/core';

@Component({
  selector: 'app-theme-toggle',
  standalone: true,
  template: `<button (click)="toggleTheme()">Toggle theme</button>`
})
export class ThemeToggleComponent {
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    effect(() => {
      localStorage.setItem('codingvila-theme', this.theme());
      document.body.className = this.theme();
    });
  }

  toggleTheme(): void {
    this.theme.update(t => (t === 'light' ? 'dark' : 'light'));
  }
}

Expected result: each click on the button flips the theme between light and dark, saves it to localStorage, and sets the matching class on the page body. This demo overwrites any other classes on document.body, and if you use server-side rendering you'll need to guard browser-only APIs like localStorage and document.

This is a reasonable use of effect() — it's genuinely a side effect, not a derived value. The pitfall shows up when developers write an effect() that writes to another signal, which can create cascading update chains that are hard to reason about and can trigger more change detection cycles than a single computed() would. (Since Angular 19, writing to signals inside an effect no longer needs the old allowSignalWrites option, so Angular won't stop you.) If you find yourself writing effect(() => otherSignal.set(someSignal() * 2)), that should almost always be computed() instead — computed() is lazy and memoized, while effect() is scheduled to run whenever its dependencies change and has no built-in way to prevent redundant downstream writes. Also worth noting: effect() must be created within an injection context, same as toSignal(), and Angular will throw a clear runtime error if you try to create one outside of it.

Zoneless Change Detection: Stable, and the Default for New Apps

Zoneless mode removes Zone.js from the picture entirely. Instead of patching async browser APIs, Angular runs change detection only when it is explicitly notified: when a signal read in a template is updated, when a template or host listener fires, when markForCheck() is called, when an async pipe emits, or when a view is removed. Zoneless change detection became stable in Angular 20.2 (enabled with provideZonelessChangeDetection()) and is the default for new apps in Angular 21. Apps that still need Zone.js can opt back in with provideZoneChangeDetection(), and Zone.js is not deprecated.

You don't have to migrate an existing app right away. But OnPush and Signals are exactly what make an app ready for zoneless, so the work in this article moves you in that direction. Before switching, make sure UI state lives in signals (or reaches the template through the async pipe or markForCheck()). In a zoneless app, changing a plain property inside a setTimeout or a promise callback no longer updates the screen on its own. Reactive Forms methods such as setValue() and patchValue() may also not trigger a UI update by themselves, so test form-heavy screens carefully.

Track Items in List Rendering (trackBy and @for track)

This one's well covered elsewhere but still worth including because it's cheap to get wrong. When rendering a list with @for (the modern control flow syntax) or the older *ngFor, Angular needs a way to identify which DOM nodes correspond to which data items across re-renders. If it matches items by object identity, then replacing an array with new object instances (which happens after every HTTP response, even when the data is identical) causes every DOM node to be destroyed and recreated. Tracking by a stable identifier like id avoids that.

typescript
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { OrderSummaryComponent } from './order-summary.component';

@Component({
  selector: 'app-order-list',
  standalone: true,
  imports: [OrderSummaryComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (order of orders(); track order.id) {
      <app-order-summary [order]="order" />
    }
  `
})
export class OrderListComponent {
  orders = input.required<Order[]>();
}

The modern @for syntax requires a track expression — here, order.id — which tells Angular to match DOM nodes to data items by a stable identifier rather than by object identity. This avoids unnecessary DOM churn when the array is replaced but most of the individual items haven't actually changed. With the older *ngFor, you get the same effect by passing a trackBy function.

Benchmarking — How to Verify the Improvement

Go back to the profiling approach from earlier and apply it consistently. Record the same user interaction before and after each change, using Angular DevTools' Profiler. What you want to compare across the two recordings:

  • The number of change detection cycles triggered during the interaction.
  • Which components were checked in each cycle — ideally, after your changes, components unrelated to the interaction should no longer appear in the recording at all.
  • The total recorded time for the interaction, as a rough proxy, though this is sensitive to machine load and shouldn't be treated as a precise benchmark across different runs or machines.

A simple, repeatable test case works better than trying to benchmark your whole app at once. For example: type a five-character search term into a filter box bound to a 500-item list, and record how many times a sibling, unrelated component (like a header or a sidebar widget) gets checked during that interaction. Before OnPush and Signals, it's common to see that sibling checked once per keystroke. After, it should not appear in the profile at all, because nothing it depends on changed.

Resist the urge to publish or rely on specific percentage improvement numbers pulled from someone else's benchmark — the actual gain depends heavily on your component tree's size, how deeply nested the affected components are, and how much unrelated work is happening elsewhere on the page. Measure your own app, on your own data shape, and treat that number as the only one that matters for your decision-making.

Production Considerations

Migrating an entire codebase to OnPush and Signals in one pass is risky and usually unnecessary. A more realistic approach for an existing Angular app is incremental: start with the components that your profiling shows are the biggest offenders — typically, items in long lists, dashboard widgets, and anything rendered inside a loop — and convert those first.

If you upgrade to Angular 22, ng update adds changeDetection: ChangeDetectionStrategy.Eager to components that didn't set a strategy, so upgrading doesn't change their behavior. You can then migrate components to OnPush one at a time by removing Eager after you've fixed any in-place mutations.

When mixing Signals with NgRx or another RxJS-based store, you don't need to migrate the store itself. Use toSignal() at the point where a component reads from the store's selector Observable, and keep dispatching actions and selecting state through the store as before. This avoids a large, risky rewrite and lets you get the rendering benefits of Signals without touching your state management architecture. Be careful not to create duplicate subscriptions, though — if a component already uses the async pipe for a selector and you also wrap the same Observable in toSignal() elsewhere in the same component, you'll end up with two independent subscriptions to the same stream, which is wasted work, not a performance win.

Third-party component libraries that haven't been updated for Signals can be a source of subtle update bugs. If a library component wraps your Signal-driven data in its own internal @Input() handling and runs under the eager strategy internally, you may not get the full benefit of your changes until that library also adopts OnPush or Signals internally. Check the library's changelog or source before assuming full compatibility, and don't assume a library "supports Signals" just because it compiles without errors against a current Angular version.

Finally, be honest with your team about the migration cost. Converting a decorator-based @Input() to a signal-based input() is mechanical but still touches every consumer of that component's API, including tests. Budget real time for it rather than treating it as a drive-by refactor.

Common Errors and How to Fix Them

  • The UI doesn't update after data changes in an OnPush component: you probably mutated an object or array in place. Return a new object or array (for example with the spread syntax), or store the state in a signal.
  • No pipe found with name 'currency' (error NG8004) in a standalone component: add CurrencyPipe to the component's imports array.
  • An error mentioning an injection context (NG0203) when using toSignal() or effect(): move the call into a constructor or field initializer, or pass an injector option where the API supports it.
  • The UI doesn't update after a setTimeout or promise callback in a zoneless app: the plain property changed, but nothing notified Angular. Store the value in a signal instead.
  • A form value changes but the screen doesn't in a zoneless app: Reactive Forms calls like patchValue() may need a signal update or markForCheck() to refresh the view.

Frequently Asked Questions

Is OnPush the default change detection strategy in Angular?

Only in Angular 22 and later. In Angular 21 and earlier, components without a strategy use Default. In Angular 22 that strategy was renamed Eager, and components without a strategy use OnPush.

Does zoneless change detection replace OnPush?

No. Zoneless controls how Angular schedules change detection, while OnPush controls which components take part in it. They work well together.

Do Signals replace RxJS in Angular?

No. Signals are well suited to state that templates read, and Observables remain well suited to event streams and HttpClient calls. Use toSignal() and toObservable() to move between the two.

Do I need to remove Zone.js to use Signals?

No. Signals work in apps with or without Zone.js, and you can adopt them incrementally.

Quick-Reference Checklist

  • Profile with Angular DevTools (in a development build) before changing anything — don't optimize based on a guess.
  • Apply ChangeDetectionStrategy.OnPush to components that don't need eager checking, starting with list items and dashboard widgets. It is the default for new components in Angular 22, but set it explicitly on Angular 21 and earlier.
  • Always produce new object/array references when updating data bound to OnPush components — never mutate in place.
  • Replace template method calls with computed() signals or pre-calculated properties.
  • Use signal() and computed() for component-local state that drives the template.
  • Use input() for new components' inputs where feasible; it integrates directly into the signal dependency graph.
  • Bridge existing RxJS Observables with toSignal() rather than rewriting your whole state layer at once.
  • Reserve effect() for genuine side effects, not derived values — use computed() for the latter.
  • Add track expressions to every @for loop.
  • Consider zoneless change detection once your UI state lives in signals. It is stable since Angular 20.2 and the default for new apps in Angular 21.
  • Re-profile after each change and compare against your saved baseline recording, not against someone else's published numbers.

Conclusion

The core idea behind Angular change detection performance optimization with Signals and OnPush is narrowing both when a component gets checked and which components need re-evaluating, instead of letting Angular walk the entire tree on every async event. OnPush handles the "when," immutability makes OnPush actually work, and Signals handle the "what" by telling Angular exactly which components read which state, without requiring manual markForCheck() calls. You now have a concrete path: profile first with Angular DevTools, convert your worst offenders to OnPush, introduce Signals and signal-based inputs incrementally, bridge existing RxJS code with toSignal() rather than rewriting it, and re-measure against your own baseline rather than trusting generic benchmark claims. As a next step, pick the single slowest interaction in your app, record a profiler baseline today, and convert just the components involved in that interaction — that's a realistic, low-risk first pass at this migration.

Codingvila provides articles and blogs on web and software development for beginners as well as free Academic projects for final year students in Asp.Net, MVC, C#, Vb.Net, SQL Server, Angular Js, Android, PHP, Java, Python, Desktop Software Application and etc.

If you have any questions, contact us on info.codingvila@gmail.com