AngularJSPerformance Optimization

Performance Optimization in Angular

Angular applications can be made extremely fast with the right techniques. Performance optimization spans multiple layers: change detection, bundle size, rendering, network requests, and data management.

This guide covers practical techniques from beginner to advanced, with measurable impact.

Change Detection Strategies

Angular's default change detection checks every component on every event. The OnPush strategy limits checks to components whose inputs have changed.

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

// Default — checks on every browser event
@Component({
  selector: 'app-slow',
  changeDetection: ChangeDetectionStrategy.Default, // Bad for performance
  template: `<p>{{ heavyComputation() }}</p>`,
})
export class SlowComponent {
  heavyComputation() {
    // Called on every keypress, mouse move, etc.
    return someExpensiveOperation();
  }
}

// OnPush — only checks when inputs change, events fire, or CD explicitly triggered
@Component({
  selector: 'app-fast',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ user.name }}</p>`,
})
export class FastComponent {
  @Input() user!: { name: string }; // CD runs only when this reference changes
}
Note
With OnPush, passing a new object reference triggers change detection. Mutating the existing object does NOT. Always create new object references when updating state: user = { ...user, name: 'Alice' }
Signals — The Future of Change Detection

Angular Signals (stable since v17) provide fine-grained reactivity — only the specific DOM node that reads a signal updates when the signal changes. No zone.js, no sweeping change detection.

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

@Component({
  standalone: true,
  selector: 'app-signals-perf',
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ doubled() }}</p>
    <button (click)="increment()">+</button>
  `,
  // No need for ChangeDetectionStrategy.OnPush — Signals are automatically efficient
})
export class SignalsPerfComponent {
  count = signal(0);
  doubled = computed(() => this.count() * 2);

  increment() {
    this.count.update((n) => n + 1);
    // Only the DOM nodes reading count() and doubled() update
    // The rest of the component tree is untouched
  }
}
Lazy Loading

Lazy loading splits your application into smaller bundles loaded only when needed. This dramatically reduces the initial bundle size:

TS
// app.routes.ts — lazy load feature modules
export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
  },
  {
    path: 'admin',
    // Entire admin section loaded only when user navigates to /admin
    loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes),
  },
  {
    path: 'reports',
    canMatch: [() => inject(AuthService).isAdmin()],
    loadChildren: () => import('./reports/reports.routes').then(m => m.reportsRoutes),
  },
];
Tip
Use Chrome DevTools Network tab to verify lazy chunks only load when navigating to the route. You should see separate chunk-HASH.js files loading on demand.
trackBy in @for Loops

Without track, Angular re-creates all DOM nodes when a list changes. With track, it reuses existing nodes and only updates what changed:

HTML
<!-- Without track — recreates ALL li elements on every list update -->
@for (item of items; track $index) {
  <li>{{ item.name }}</li>
}

<!-- With track by unique ID — reuses existing DOM nodes efficiently -->
@for (item of items; track item.id) {
  <li>{{ item.name }}</li>
}

<!-- Old *ngFor syntax equivalent -->
<li *ngFor="let item of items; trackBy: trackById">{{ item.name }}</li>

TS
// For *ngFor (module-based)
trackById(index: number, item: Item): number {
  return item.id;
}
Deferrable Views (@defer)

Angular 17's @defer block lets you defer loading heavy components until they're needed. The component's JavaScript is split into a separate chunk and only loaded when the trigger fires:

HTML
<!-- Defer a heavy component until it enters the viewport -->
@defer (on viewport) {
  <app-heavy-chart [data]="chartData" />
} @placeholder {
  <div class="chart-placeholder">Loading chart...</div>
} @loading {
  <app-spinner />
} @error {
  <p>Failed to load chart</p>
}

<!-- Defer on user interaction -->
@defer (on interaction) {
  <app-comment-section [postId]="postId" />
} @placeholder {
  <button>Show Comments</button>
}

<!-- Defer on idle -->
@defer (on idle) {
  <app-analytics-widget />
}

<!-- Defer after a timer -->
@defer (on timer(2s)) {
  <app-notification-banner />
}
Pure Pipes vs. Methods in Templates

Template method calls execute on every change detection cycle. Pure pipes only recalculate when inputs change:

TS
// ❌ Bad — fullName() called on every change detection cycle
@Component({ template: `<p>{{ fullName() }}</p>` })
export class BadComponent {
  firstName = 'John';
  lastName = 'Doe';

  fullName() {
    return `${this.firstName} ${this.lastName}`; // Runs on EVERY CD cycle
  }
}

// ✅ Good — use a getter + OnPush
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ fullName }}</p>`,
})
export class BetterComponent {
  @Input() firstName = 'John';
  @Input() lastName = 'Doe';

  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

// ✅ Best for expensive transformations — use a pure pipe
@Pipe({ name: 'fullName', standalone: true, pure: true })
export class FullNamePipe implements PipeTransform {
  transform(user: { firstName: string; lastName: string }): string {
    return `${user.firstName} ${user.lastName}`; // Only recalculates when user reference changes
  }
}
Virtual Scrolling

For long lists (100+ items), render only what's visible using Angular CDK's virtual scrolling:

Bash
npm install @angular/cdk

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

@Component({
  standalone: true,
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="50" style="height: 500px">
      @for (item of items; track item.id) {
        <div *cdkVirtualFor="let item of items" style="height: 50px">
          {{ item.name }}
        </div>
      }
    </cdk-virtual-scroll-viewport>
  `,
})
export class VirtualListComponent {
  items = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
}
HTTP Optimization

TS
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, shareReplay, tap } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class DataService {
  private http = inject(HttpClient);

  // Cache API response — shareReplay(1) reuses last value for new subscribers
  private users$ = this.http.get<User[]>('/api/users').pipe(
    shareReplay(1), // Cache and share the last emission
    tap(() => console.log('HTTP request made'))
  );

  // Only makes one HTTP call even with multiple subscribers
  getUsers(): Observable<User[]> {
    return this.users$;
  }
}

TS
// Debounce search to prevent API calls on every keystroke
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';

@Component({
  standalone: true,
  imports: [ReactiveFormsModule, AsyncPipe],
  template: `
    <input [formControl]="searchControl" />
    @for (result of results$ | async; track result.id) {
      <li>{{ result.name }}</li>
    }
  `,
})
export class SearchComponent {
  searchControl = new FormControl('');

  results$ = this.searchControl.valueChanges.pipe(
    debounceTime(300),           // Wait 300ms after last keystroke
    distinctUntilChanged(),      // Skip if same value
    switchMap((query) =>
      query ? this.searchService.search(query) : of([])
    ),
  );
}
Image Optimization (NgOptimizedImage)

Angular 15+ includes NgOptimizedImage directive for automatic image optimization:

TS
import { NgOptimizedImage } from '@angular/common';

@Component({
  standalone: true,
  imports: [NgOptimizedImage],
  template: `
    <!-- Automatically adds width/height, lazy loading, priority loading -->
    <img ngSrc="hero.jpg" width="800" height="400" priority />

    <!-- Lazy loaded (default)  -->
    <img ngSrc="product.jpg" width="300" height="300" alt="Product" />

    <!-- Responsive with fill -->
    <div style="position:relative; height:400px">
      <img ngSrc="banner.jpg" fill alt="Banner" />
    </div>
  `,
})
export class ImageComponent {}
Zone.js Optimization and Zoneless

Zone.js patches browser APIs to trigger change detection automatically. You can opt out for better performance in specific cases:

TS
// Run code outside Angular's zone (avoids triggering CD)
import { NgZone, inject } from '@angular/core';

@Component({ standalone: true, selector: 'app-timer', template: '' })
export class TimerComponent {
  private zone = inject(NgZone);

  startHeavyLoop() {
    // Run outside zone — won't trigger CD on each iteration
    this.zone.runOutsideAngular(() => {
      setInterval(() => {
        // Heavy computation that doesn't affect the UI
        processData();
      }, 100);
    });
  }

  updateUI(data: any) {
    // Bring it back into the zone when you DO need CD
    this.zone.run(() => {
      this.result = data;
    });
  }
}

TS
// Experimental zoneless mode (Angular 18+)
// app.config.ts
import { provideExperimentalZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(), // No zone.js needed
    provideRouter(routes),
  ],
};
Performance Checklist

Technique

Impact

Difficulty

OnPush + Signals

Very High

Low

Lazy loading routes

Very High

Low

@defer blocks

High

Low

Virtual scrolling

High (large lists)

Medium

track in @for

High

Low

Pure pipes over methods

Medium

Low

shareReplay for HTTP

Medium

Low

NgOptimizedImage

Medium

Low

runOutsideAngular

Medium

Medium

Zoneless mode

High

High

Measuring Performance
  1. Angular DevTools Profiler — record and analyze change detection cycles

  2. Chrome DevTools Performance tab — CPU flame chart, layout/paint events

  3. Lighthouse — automated PWA/performance score (run from DevTools)

  4. ng build --stats-json + webpack-bundle-analyzer for bundle analysis

  5. Core Web Vitals report in Google Search Console for real-user data