Dec 16, 2025

Angular ChangeDetectorRef Guide: Performance Tuning & Common Pitfalls

Tags

After mastering Default vs OnPush strategies, I hit a wall with a real-time stock ticker dashboard. Even with OnPush everywhere, certain components lagged during market hours. That's when I dove into ChangeDetectorRef APIs and uncovered common performance traps. This article shares the exact techniques that took my app from 2.3s to 180ms load times.


angular-change-detectorref


ChangeDetectorRef: Fine-Grained Control


ChangeDetectorRef gives you manual control over when Angular checks specific components. Import it and inject into OnPush components:


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

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class StockTickerComponent {
  constructor(private cdr: ChangeDetectorRef) {}
}


The Four Essential Methods


1. markForCheck() – The OnPush Hero


Marks this component + ancestors for the next change detection cycle.


  // Perfect for async callbacks in OnPush components
fetchStockData() {
  this.stockService.getPrice().subscribe(price => {
    this.currentPrice = price;
    this.cdr.markForCheck(); // Triggers next CD cycle
  });
}


When to use: Observables, timers, Promises in OnPush components.


2. detectChanges() – Run CD Right Now


Immediately runs change detection on this component + children.


// For third-party widgets or canvas updates
updateChart() {
  this.chart.updateData(this.data);
  this.cdr.detectChanges(); // DOM updates instantly
}


Caution: Can cause infinite loops if overused.


3. detach() / reattach() – Complete Opt-Out


Removes component from automatic change detection entirely.


ngOnInit() {
  if (this.isStaticContent) {
    this.cdr.detach(); // Never checked automatically
  }
}

updateDynamicContent() {
  this.cdr.reattach(); // Back in the game
  this.cdr.detectChanges();
}


Use case: Static banners, readonly displays, heavy animations.


Common Performance Killers (And Their Fixes)


1. Template Computations – The Silent Killer



Heavy work in templates (runs every CD cycle)
<div>{{ expensiveFilter(users) }}</div>

 Move to component or use pure pipes
@Pipe({ pure: true })
export class FilterPipe { transform(users: User[]) { ... } }


2. ngFor Without trackBy – List Re-Render Hell



Destroys/recreates ALL DOM nodes on array changes
<div *ngFor="let user of users">{{ user.name }}</div>

Only updates changed items
<div *ngFor="let user of users; trackBy: trackByUserId">{{ user.name }}</div>

trackByUserId(index: number, user: User): number {
  return user.id; // Stable identifier
}

Impact: 1000 items → 85% render time reduction.


3. Mutable State in OnPush – The Gotcha



Won't trigger OnPush (same reference!)
this.users[0].name = 'Updated';

Creates new reference → triggers OnPush
this.users = this.users.map((u, i) => 
  i === 0 ? { ...u, name: 'Updated' } : u
);


My Performance Optimization Checklist


1. OnPush on all presentational/list components

2. trackBy on every *ngFor

3. Pure pipes for filtering/sorting

4. Immutable updates (spread operator, map/filter)

5. async pipe over manual subscribe()

6. PurePipe for expensive template logic

7. Lazy load feature modules

8. Signals for local component state (Angular 17+)


Real-World Example: The Laggy Dashboard Fix


Before (2.3s render):


@Component({ /* Default strategy */ })
export class DashboardComponent {
  users = []; // Mutable array
  
  ngOnInit() {
    this.userService.getUsers().subscribe(users => {
      this.users = users; // Direct assignment
    });
  }
}


Template: <div *ngFor="let user of filterUsers(users)">{{ user.name }}</div>

After (180ms render):


@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class DashboardComponent {
  users = signal<User[]>([]);
  filteredUsers = computed(() => 
    this.users().filter(u => u.active)
  );
  
  constructor(private cdr: ChangeDetectorRef) {}
  
  ngOnInit() {
    this.userService.getUsers().subscribe(users => {
      this.users.set(users); // Immutable signal update
      // No cdr.markForCheck() needed with signals!
    });
  }
}


Template: <div *ngFor="let user of filteredUsers(); trackBy: trackById">


Benchmark Results (Chrome Performance Tab)


Component Tree: 120 components

Default: 2,347ms per update

OnPush: 892ms per update (62% improvement)

OnPush+trackBy: 428ms per update

Signals+OnPush: 182ms per update (92% total improvement!)


When Signals Replace ChangeDetectorRef (Angular 17+)


Signals often eliminate manual markForCheck() calls entirely:


// No cdr needed! Template reads trigger automatic CD
readonly users = signal<User[]>([]);
readonly filteredUsers = computed(() => this.users().filter(u => u.active));

ngOnInit() {
  this.userService.getUsers().subscribe(users => {
    this.users.set(users); // Auto-triggers template update
  });
}

Migration path: OnPush → Signals → Zoneless (future).


Next: Signals vs traditional change detection + real-world debugging scenarios that trip up every Angular developer.


EmoticonEmoticon