| |

Angular 95 ๐Ÿ…ฐ๏ธ Performance โ€” Rendering and Change Detection

โฑ๏ธ Estimated read: 18 min | |

Every Angular application has a change detection cycle. When something changes โ€” a user clicks a button, an HTTP response arrives, a timer fires โ€” Angular checks whether the data that templates depend on has changed, and if it has, it updates the DOM. The cycle is the engine of the framework. It is also the most common source of performance problems in large applications.

The previous chapters covered components, services, state management, and the CDK. This chapter covers the layer underneath all of them: the mechanism that decides when to render and how much to check. Angular has two change detection strategies โ€” Default and OnPush โ€” and two reactivity models โ€” Zone.js and signals. Understanding the difference between them is the difference between an application that stays fast as it grows and one that slows down with every feature added.

Key point: Angular’s change detection is a tree walk. Starting from the root component, Angular checks each component in the tree to see whether its template bindings have changed. By default, it checks every component on every cycle. With OnPush, it checks a component only when one of its inputs changes, an event fires from it, a signal it reads changes, or it is explicitly marked for check. The second approach is the foundation of performance in large applications.


Why change detection matters

A small application with a handful of components can afford to check everything on every cycle. A large application with hundreds of components cannot. The cost of a full tree walk grows with the number of components and the complexity of their templates.

The zone problem. Zone.js is the mechanism that triggers change detection in traditional Angular applications. It monkey-patches every asynchronous API โ€” setTimeout, Promise, event listeners, HTTP requests โ€” and fires a change detection cycle when the microtask queue empties. This is what makes Angular’s “just mutate a property and the view updates” ergonomics possible. It is also why a setTimeout that updates an unrelated variable still triggers a full change detection cycle. The work is done regardless of whether the data actually changed .

The OnPush problem. A component with ChangeDetectionStrategy.OnPush is skipped during the tree walk unless something tells Angular it might have changed. The triggers are: an @Input reference change, an event fired from the component or its children, an observable bound with the async pipe emitting, a signal read in the template changing, or an explicit markForCheck(). When none of these happen, the component is not checked. The savings add up across the tree .

The signals problem. Signals are Angular’s answer to the imprecision of Zone.js. A signal that is read in a template is registered as a dependency. When the signal updates, Angular knows exactly which components depend on it and marks only those components for check. It does not need to walk the tree to find out. This is the most precise form of change detection the framework offers .

The rendering problem. Change detection is not the same as DOM rendering. Change detection determines whether a binding value changed. Rendering is the browser’s job of painting the DOM. A component that is checked but whose bindings did not change does not trigger any DOM updates. The cost is the checking itself, not the DOM manipulation .

The trade-off. OnPush and signals require discipline. A component that mutates an object without changing its reference will not be detected. A signal that is not read in a template is not a dependency. The developer must understand the rules to use the tools correctly. The rules are simple, but they must be followed.


a. Default vs OnPush Change Detection

Every Angular component has a change detection strategy. The default is ChangeDetectionStrategy.Default, which means the component is checked on every cycle. The alternative is ChangeDetectionStrategy.OnPush, which means the component is checked only when one of the triggers fires.

The strategy is declared in the @Component decorator:

@Component({
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h3>{{ user().name }}</h3>
    <p>{{ user().email }}</p>
  `,
})
export class UserCardComponent {
  user = input.required<User>();
}

With OnPush, the component is checked when:

  • The user input receives a new reference (not a mutation).
  • An event handler in the component or its children fires.
  • An observable bound with the async pipe emits.
  • A signal read in the template changes.
  • ChangeDetectorRef.markForCheck() is called.

The most common mistake is mutating an input object without changing its reference. With Default, the mutation is detected because the component is checked on every cycle. With OnPush, the mutation is invisible because the component is not checked.

// โŒ Mutating an object does not trigger OnPush change detection
this.user.name = 'New Name';

// โœ… Replacing the object with a new reference does
this.user = { ...this.user, name: 'New Name' };

The same rule applies to arrays. push, splice, and other mutating methods do not trigger OnPush change detection. Returning a new array does.


b. Signals and Fine-Grained Reactivity

Signals change the change detection model. A signal is a reactive value that tracks its own dependencies. When a signal is read in a template, Angular registers the component as a consumer. When the signal updates, Angular marks only the consumers for check.

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

@Component({
  selector: 'app-counter',
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ double() }}</p>
    <button (click)="increment()">+</button>
  `,
})
export class CounterComponent {
  count = signal(0);
  double = computed(() => this.count() * 2);

  increment(): void {
    this.count.update((v) => v + 1);
  }
}

The count signal is read in the template. Angular knows the component depends on it. When increment() updates the signal, Angular marks the component for check. The double computed signal depends on count. When count changes, double is invalidated and recomputed on the next read.

Signals work with both Default and OnPush. With OnPush, a signal update marks the component for check even if no input changed and no event fired. This makes signals the most reliable trigger for OnPush components.

The untracked function reads a signal without registering a dependency. This is useful when a signal is read inside a computed or effect but should not trigger a recomputation.

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

const a = signal(1);
const b = signal(2);

const sum = computed(() => a() + untracked(() => b()));
// sum only recomputes when a changes

The effect function runs a side effect whenever the signals it reads change. It is the equivalent of a subscription that Angular manages automatically.

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

const count = signal(0);

effect(() => {
  console.log('Count changed:', count());
});

Effects are for side effects, not for rendering. They run after change detection and are cleaned up when the component is destroyed.


c. Optimizing Rendering

Change detection strategy is the first lever. The second is the rendering itself: how much DOM is created, how often it is updated, and how the browser is told about the changes.

Avoid function calls in templates. A function call in a template is executed on every change detection cycle. If the function does work โ€” sorting, filtering, formatting โ€” the work is repeated on every cycle. The fix is to use a computed signal or a pure pipe. A pure pipe caches its result and only recomputes when its input changes.

// โŒ The function runs on every change detection cycle
{{ formatDate(user.createdAt) }}

// โœ… The computed signal recomputes only when the input changes
{{ formattedDate() }}

// โœ… Or use a pure pipe
{{ user.createdAt | date:'medium' }}

Use track in @for loops. The track expression tells Angular how to identify each item in the list. Without it, Angular re-renders the entire list when the collection changes. With it, Angular moves, adds, and removes only the items that changed.

@for (item of items(); track item.id) {
  <div>{{ item.name }}</div>
}

The track expression should be a stable identifier, not the array index. Using the index means that inserting an item at the beginning causes every subsequent item to be re-rendered because its index changed.

Defer non-critical content with @defer. The @defer block lazy-loads its content and its dependencies. The content is not downloaded or rendered until a trigger fires. This reduces the initial bundle and the initial render cost.

@defer (on viewport) {
  <heavy-chart />
} @placeholder {
  <div>Chart placeholder</div>
}

Use virtual scrolling for large lists. The CDK’s cdk-virtual-scroll-viewport renders only the visible items. A list of 100,000 items renders as if it had only the visible ones. This is the difference between a list that scrolls smoothly and one that janks on every scroll event.

Avoid unnecessary change detection with NgZone.runOutsideAngular. Some operations โ€” animations, scroll listeners, mouse-move trackers โ€” do not need to trigger change detection. Running them outside the Angular zone prevents the change detection cycle from firing on every event.

import { NgZone } from '@angular/core';

constructor(private ngZone: NgZone) {
  this.ngZone.runOutsideAngular(() => {
    element.addEventListener('mousemove', () => {
      // does not trigger change detection
    });
  });
}

The trade-off is that the handler must explicitly re-enter the zone when it needs to update Angular state.


Complete Example Session

This session demonstrates the performance patterns by building a list that stays fast as it grows.

// ============================================
// PART 1: THE ONPUSH COMPONENT
// ============================================

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

@Component({
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card">
      <h3>{{ user().name }}</h3>
      <p>{{ user().email }}</p>
    </div>
  `,
})
export class UserCardComponent {
  user = input.required<User>();
}

The component uses OnPush. It is only checked when the user input receives a new reference. Since the input is a signal, the check is triggered when the signal changes.

// ============================================
// PART 2: THE SIGNAL-BASED LIST
// ============================================

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

interface User {
  id: number;
  name: string;
  email: string;
  role: string;
}

@Component({
  selector: 'app-user-list',
  template: `
    <input
      type="text"
      placeholder="Filter by name"
      (input)="onFilter($event)"
    />
    <p>Showing {{ filteredUsers().length }} of {{ users().length }} users</p>
    <ul>
      @for (user of filteredUsers(); track user.id) {
        <li>{{ user.name }} โ€” {{ user.role }}</li>
      }
    </ul>
  `,
})
export class UserListComponent {
  users = signal<User[]>([
    { id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
    { id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'User' },
    { id: 3, name: 'Carol Williams', email: 'carol@example.com', role: 'Editor' },
  ]);

  filter = signal('');

  filteredUsers = computed(() => {
    const term = this.filter().toLowerCase();
    if (!term) return this.users();
    return this.users().filter((u) => u.name.toLowerCase().includes(term));
  });

  onFilter(event: Event): void {
    const input = event.target as HTMLInputElement;
    this.filter.set(input.value);
  }
}

The filteredUsers computed signal derives the filtered list from the users signal and the filter signal. It recomputes only when one of them changes. The track user.id expression tells Angular how to identify each item.

// ============================================
// PART 3: THE VIRTUAL SCROLL LIST
// ============================================

import { Component } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  selector: 'app-large-list',
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="50" class="viewport">
      <div *cdkVirtualFor="let item of items; trackBy: trackById" class="item">
        {{ item }}
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: [`
    .viewport { height: 400px; width: 100%; }
    .item { height: 50px; display: flex; align-items: center; padding: 0 16px; }
  `],
})
export class LargeListComponent {
  items = Array.from({ length: 100000 }, (_, i) => `Item #${i}`);
  trackById(index: number, item: string): string {
    return item;
  }
}

The cdk-virtual-scroll-viewport renders only the visible items. The itemSize must match the actual height of each item. The trackBy function helps Angular identify items when the list scrolls.

// ============================================
// PART 4: THE DEFERRED HEAVY COMPONENT
// ============================================

import { Component } from '@angular/core';

@Component({
  selector: 'app-dashboard',
  template: `
    <h1>Dashboard</h1>
    <app-summary />

    @defer (on viewport) {
      <app-heavy-chart />
    } @placeholder {
      <div class="chart-placeholder">Chart loads when visible</div>
    } @loading (minimum 500ms) {
      <div class="spinner">Loading chart...</div>
    }
  `,
})
export class DashboardComponent {}

The @defer block lazy-loads the heavy chart. The placeholder is shown until the chart enters the viewport. The loading state is shown while the chart’s code is being downloaded.

// ============================================
// PART 5: THE PURE PIPE
// ============================================

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'truncate',
  standalone: true,
  pure: true,
})
export class TruncatePipe implements PipeTransform {
  transform(value: string, limit: number = 50): string {
    return value.length > limit ? value.substring(0, limit) + 'โ€ฆ' : value;
  }
}

The truncate pipe is pure. It recomputes only when the input value changes. Without pure: true (the default), the pipe would run on every change detection cycle.

// ============================================
// PART 6: THE ZONE OPTIMIZATION
// ============================================

import { Component, NgZone, ElementRef, viewChild, AfterViewInit, OnDestroy } from '@angular/core';

@Component({
  selector: 'app-scroll-tracker',
  template: `
    <div #scrollArea class="scroll-area">
      <p>Scroll position: {{ scrollPosition }}</p>
    </div>
  `,
})
export class ScrollTrackerComponent implements AfterViewInit, OnDestroy {
  private ngZone = inject(NgZone);
  private scrollArea = viewChild.required<ElementRef>('scrollArea');
  scrollPosition = 0;
  private cleanup?: () => void;

  ngAfterViewInit(): void {
    this.ngZone.runOutsideAngular(() => {
      const element = this.scrollArea().nativeElement;
      const handler = () => {
        this.scrollPosition = element.scrollTop;
      };
      element.addEventListener('scroll', handler);
      this.cleanup = () => element.removeEventListener('scroll', handler);
    });
  }

  ngOnDestroy(): void {
    this.cleanup?.();
  }
}

The scroll listener runs outside the Angular zone. It updates the scrollPosition property without triggering change detection. The trade-off is that the template does not update unless the handler re-enters the zone. For a scroll position display, this is often acceptable.

// ============================================
// PART 7: THE TRACK EXPRESSION
// ============================================

@Component({
  selector: 'app-tracked-list',
  template: `
    @for (user of users(); track user.id) {
      <app-user-card [user]="user" />
    }
  `,
})
export class TrackedListComponent {
  users = signal<User[]>([]);
}

The track user.id expression tells Angular to identify each item by its id property. When the list changes, Angular moves, adds, and removes only the items that changed. Without track, Angular re-renders the entire list.

// ============================================
// PART 8: THE COMPUTED SIGNAL
// ============================================

@Component({
  selector: 'app-summary',
  template: `
    <p>Total: {{ total() }}</p>
    <p>Active: {{ activeCount() }}</p>
    <p>Average: {{ average() | number:'1.2-2' }}</p>
  `,
})
export class SummaryComponent {
  items = input.required<Item[]>();

  total = computed(() => this.items().length);
  activeCount = computed(() => this.items().filter((i) => i.active).length);
  average = computed(() => {
    const items = this.items();
    if (items.length === 0) return 0;
    return items.reduce((sum, i) => sum + i.value, 0) / items.length;
  });
}

The computed signals derive the summary values from the items input. They recompute only when the input changes. The template reads the computed values without triggering a function call on every cycle.

// ============================================
// PART 9: THE UNTRACKED READ
// ============================================

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

const items = signal<Item[]>([]);
const filter = signal('');

const filteredItems = computed(() => {
  const term = filter();
  return untracked(() => items()).filter((i) => i.name.includes(term));
});

The untracked function reads the items signal without registering a dependency. The filteredItems computed signal depends only on filter. When items changes, filteredItems is not invalidated. This is useful when the items are stable and only the filter changes.

// ============================================
// PART 10: THE PERFORMANCE CHECKLIST
// ============================================

// 1. Use OnPush on every component that does not need Default
// 2. Use signals for reactive state
// 3. Use computed signals for derived values
// 4. Never call functions in templates
// 5. Always use track in @for loops
// 6. Use @defer for non-critical content
// 7. Use virtual scrolling for large lists
// 8. Use runOutsideAngular for non-Angular events
// 9. Use pure pipes instead of impure pipes
// 10. Profile with Angular DevTools before optimizing

The ten parts cover the OnPush component, the signal-based list, the virtual scroll list, the deferred heavy component, the pure pipe, the zone optimization, the track expression, the computed signal, the untracked read, and the performance checklist.


Quick Reference

The Change Detection Strategies

StrategyBehavior
DefaultChecked on every cycle
OnPushChecked when a trigger fires

The OnPush Triggers

TriggerExample
Input reference changeuser = input.required<User>()
Event from component(click)="handleClick()"
Async pipe emission{{ data$ | async }}
Signal read change{{ count() }}
markForCheck()cdr.markForCheck()

The Signal API

FunctionPurpose
signal(initial)Create a writable signal
computed(fn)Create a derived signal
effect(fn)Run a side effect
untracked(fn)Read without dependency

The Rendering Optimizations

TechniqueBenefit
Computed signalsNo function calls in templates
Pure pipesCached results
track in @forMinimal DOM updates
@deferLazy loading
Virtual scrollingOnly visible items rendered
runOutsideAngularNo change detection on non-Angular events

The Track Expression

Track ExpressionEffect
track item.idStable identity
track $indexIndex-based (avoid)
track itemReference-based

Best Practices

โœ… Do This:

// Use OnPush on components
changeDetection: ChangeDetectionStrategy.OnPush                     // โœ…
// Use signals for reactive state
count = signal(0);                                                  // โœ…
// Use computed for derived values
double = computed(() => this.count() * 2);                          // โœ…
<!-- Use track in @for loops -->
@for (item of items(); track item.id) { ... }                        <!-- โœ… -->
<!-- Use @defer for non-critical content -->
@defer (on viewport) { <heavy-chart /> }                             <!-- โœ… -->
// Run non-Angular events outside the zone
this.ngZone.runOutsideAngular(() => { ... });                       // โœ…

โŒ Don’t Do This:

<!-- Don't call functions in templates -->
{{ formatDate(user.createdAt) }}                                    <!-- โŒ -->
<!-- Don't use $index in track -->
@for (item of items(); track $index) { ... }                         <!-- โŒ -->
// Don't mutate input objects in OnPush components
this.user.name = 'New Name';  // not detected                        // โŒ
<!-- Don't use impure pipes -->
@Pipe({ name: 'format', pure: false })                              <!-- โŒ -->
// Don't update Angular state from outside the zone without re-entering
this.ngZone.runOutsideAngular(() => {
  this.value = 1;  // template will not update                       // โŒ
});

Common Pitfalls

PitfallWhy It HappensFix
OnPush component not updatingInput mutated, not replacedReplace the object
Slow list renderingNo track expressionAdd track item.id
Function calls on every cycleMethod in templateUse computed or pure pipe
Deferred content not loadingWrong triggerCheck @defer trigger
Scroll jankChange detection on scrollUse runOutsideAngular
Computed signal not updatingMissing dependencyEnsure signal is read

Real-World Examples

1. OnPush Strategy

changeDetection: ChangeDetectionStrategy.OnPush

2. Signal State

count = signal(0);

3. Computed Derivation

double = computed(() => this.count() * 2);

4. Track Expression

@for (item of items(); track item.id) { ... }

5. Deferred Content

@defer (on viewport) { <heavy-chart /> }

6. Virtual Scroll

<cdk-virtual-scroll-viewport itemSize="50">...</cdk-virtual-scroll-viewport>

7. Pure Pipe

@Pipe({ name: 'truncate', pure: true })

8. Outside Angular

this.ngZone.runOutsideAngular(() => { ... });

9. Untracked Read

untracked(() => items())

10. Effect

effect(() => console.log(count()));

Visual

The Default Change Detection

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  DEFAULT CHANGE DETECTION                    โ”‚
โ”‚                                              โ”‚
โ”‚  Root                                        โ”‚
โ”‚   โ”œโ”€ Component A (checked)                   โ”‚
โ”‚   โ”‚   โ”œโ”€ Component A1 (checked)              โ”‚
โ”‚   โ”‚   โ””โ”€ Component A2 (checked)              โ”‚
โ”‚   โ”œโ”€ Component B (checked)                   โ”‚
โ”‚   โ”‚   โ””โ”€ Component B1 (checked)              โ”‚
โ”‚   โ””โ”€ Component C (checked)                   โ”‚
โ”‚                                              โ”‚
โ”‚  Every component is checked on every cycle.  โ”‚
โ”‚  The cost grows with the tree.               โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The OnPush Change Detection

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  ONPUSH CHANGE DETECTION                     โ”‚
โ”‚                                              โ”‚
โ”‚  Root                                        โ”‚
โ”‚   โ”œโ”€ Component A (checked)                   โ”‚
โ”‚   โ”‚   โ”œโ”€ Component A1 (skipped)              โ”‚
โ”‚   โ”‚   โ””โ”€ Component A2 (checked via signal)   โ”‚
โ”‚   โ”œโ”€ Component B (skipped)                   โ”‚
โ”‚   โ”‚   โ””โ”€ Component B1 (skipped)              โ”‚
โ”‚   โ””โ”€ Component C (checked via input change)  โ”‚
โ”‚                                              โ”‚
โ”‚  Only components with a trigger are checked. โ”‚
โ”‚  The cost is proportional to the changes.    โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Signal Reactivity

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  SIGNAL REACTIVITY                           โ”‚
โ”‚                                              โ”‚
โ”‚  count = signal(0)                           โ”‚
โ”‚    โ”‚                                         โ”‚
โ”‚    โ”œโ”€> Template reads count()                โ”‚
โ”‚    โ”‚    โ””โ”€ Component registered as consumer  โ”‚
โ”‚    โ”‚                                         โ”‚
โ”‚    โ”œโ”€> computed double() reads count()       โ”‚
โ”‚    โ”‚    โ””โ”€ Computed registered as consumer   โ”‚
โ”‚    โ”‚                                         โ”‚
โ”‚    โ””โ”€> count.set(1)                          โ”‚
โ”‚         โ””โ”€ Both consumers marked for check   โ”‚
โ”‚                                              โ”‚
โ”‚  No tree walk. No unnecessary checks.        โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Performance Decision Flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  PERFORMANCE DECISION                        โ”‚
โ”‚                                              โ”‚
โ”‚  Is the component used in many places?       โ”‚
โ”‚    โ””โ”€ YES โ†’ OnPush                           โ”‚
โ”‚                                              โ”‚
โ”‚  Does the template call functions?           โ”‚
โ”‚    โ””โ”€ YES โ†’ Computed signal or pure pipe     โ”‚
โ”‚                                              โ”‚
โ”‚  Does the template render a list?            โ”‚
โ”‚    โ””โ”€ YES โ†’ track expression                 โ”‚
โ”‚                                              โ”‚
โ”‚  Is the content below the fold?              โ”‚
โ”‚    โ””โ”€ YES โ†’ @defer                           โ”‚
โ”‚                                              โ”‚
โ”‚  Is the list very long?                      โ”‚
โ”‚    โ””โ”€ YES โ†’ Virtual scroll                   โ”‚
โ”‚                                              โ”‚
โ”‚  Does the event fire frequently?             โ”‚
โ”‚    โ””โ”€ YES โ†’ runOutsideAngular                โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
Default strategyChecked on every cycle
OnPush strategyChecked when a trigger fires
OnPush triggersInput reference, event, async pipe, signal, markForCheck
SignalReactive value that tracks dependencies
ComputedDerived signal that recomputes on dependency change
EffectSide effect that runs on signal change
UntrackedRead without registering a dependency
Track expressionIdentifies items in @for loops
DeferLazy loads content and dependencies
Virtual scrollRenders only visible items
RunOutsideAngularRuns code without triggering change detection

Key takeaways:

  • Angular’s change detection is a tree walk. By default, every component is checked on every cycle. The cost grows with the number of components and the complexity of their templates .
  • OnPush checks a component only when a trigger fires. The triggers are: an input reference change, an event from the component, an async pipe emission, a signal read change, or an explicit markForCheck(). The component is skipped otherwise .
  • Signals are the most precise form of reactivity. A signal that is read in a template is registered as a dependency. When the signal updates, Angular marks only the consumers for check. It does not need to walk the tree to find out .
  • Computed signals derive values without function calls in templates. A computed signal recomputes only when its dependencies change. A function call in a template runs on every cycle. The difference is the source of most template performance problems .
  • The track expression is essential for list performance. Without it, Angular re-renders the entire list when the collection changes. With it, Angular moves, adds, and removes only the items that changed. Use a stable identifier, not the index .
  • @defer lazy-loads non-critical content. The content and its dependencies are downloaded and rendered only when a trigger fires. This reduces the initial bundle and the initial render cost .
  • runOutsideAngular prevents change detection on non-Angular events. Scroll listeners, mouse-move trackers, and animations do not need to trigger change detection. Running them outside the zone prevents the cycle from firing on every event. The trade-off is that the handler must explicitly re-enter the zone to update Angular state .

Remember: Performance in Angular is about doing less work. OnPush reduces the number of components checked. Signals make the checks precise. Computed signals avoid function calls. track avoids unnecessary DOM updates. @defer avoids loading content that is not needed. Virtual scrolling avoids rendering items that are not visible. runOutsideAngular avoids triggering change detection for events that do not affect Angular state. Each technique is a small optimization. Together, they keep the application fast as it grows.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!