Angular Performance Optimization

Performance optimization in Angular refers to techniques that reduce the application's load time, minimize unnecessary processing, and keep the user interface smooth and responsive. Angular applications can become slow when components re-render too frequently, large bundles take too long to download, or images and data are loaded before they are needed.

Understanding how Angular detects and processes changes is the foundation for effective optimization.

Understanding Change Detection

Angular's change detection is the process that checks whether data in the application has changed and updates the DOM accordingly. By default, Angular checks every component in the entire application whenever anything changes — including events, HTTP responses, or timers.

For large applications with many components, this default behavior can cause performance issues because Angular re-checks components even when their data has not changed.

OnPush Change Detection Strategy

The most impactful performance optimization for Angular components is switching to the OnPush change detection strategy. With OnPush, Angular skips re-rendering a component unless one of the following is true:

  • An @Input() property receives a new reference (a new object or array, not a mutation).
  • An event occurs within the component or a child component.
  • An Observable that the component subscribes to via async pipe emits a new value.
  • markForCheck() or detectChanges() is called manually.

// product-card.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-product-card',
  templateUrl: './product-card.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush  // ← Add this
})
export class ProductCardComponent {
  @Input() product!: { id: number; name: string; price: number };
}

With a list of 100 product cards, the default strategy re-checks all 100 components on every change anywhere in the application. With OnPush, each card only re-renders when its specific product input changes — a major performance improvement.

Important Note on OnPush and Immutability

With OnPush, Angular detects input changes by reference — not by value. Mutating an existing object does not trigger re-rendering:


// WRONG — mutating the existing object will NOT trigger OnPush re-render
this.product.name = 'New Name';

// CORRECT — create a new object reference to trigger OnPush re-render
this.product = { ...this.product, name: 'New Name' };

TrackBy in ngFor

By default, when a list is re-rendered, Angular destroys and recreates all DOM elements in the list — even if only one item changed. The trackBy function tells Angular how to identify each item uniquely, allowing it to reuse unchanged DOM elements:


// products.component.ts
export class ProductsComponent {
  products = [
    { id: 1, name: 'Laptop Stand' },
    { id: 2, name: 'Wireless Mouse' },
    { id: 3, name: 'USB Hub' }
  ];

  trackByProductId(index: number, product: any): number {
    return product.id;   // Angular uses this ID to track each item
  }
}

<!-- Without trackBy: all DOM elements recreated on list change -->
<li *ngFor="let product of products">{{ product.name }}</li>

<!-- With trackBy: only changed items are updated in the DOM -->
<li *ngFor="let product of products; trackBy: trackByProductId">{{ product.name }}</li>

Lazy Loading Images

Loading all images on page load — even images that are below the fold (not visible) — wastes bandwidth and slows initial load. The native HTML loading="lazy" attribute defers image loading until the image is about to enter the viewport:


<!-- Standard eager loading (default) -->
<img src="product.jpg" alt="Product Image">

<!-- Lazy loading — image downloads only when near the viewport -->
<img src="product.jpg" alt="Product Image" loading="lazy">

<!-- In a product list -->
<div *ngFor="let product of products">
  <img [src]="product.imageUrl" [alt]="product.name" loading="lazy" width="300" height="200">
</div>

Pure Pipes vs Impure Pipes

Pure pipes (the default) only re-execute the transform method when the input reference changes. Impure pipes run on every change detection cycle. Using pure pipes wherever possible avoids unnecessary recomputation:


@Pipe({
  name: 'filterActive',
  pure: true   // Default — only re-runs when input reference changes
})
export class FilterActivePipe implements PipeTransform {
  transform(items: any[]): any[] {
    return items.filter(item => item.active);
  }
}

Instead of filtering data in a template expression (which runs on every change detection cycle), use a pipe or a computed getter:


<!-- SLOW — filter() runs on every change detection cycle -->
<li *ngFor="let user of users.filter(u => u.active)">{{ user.name }}</li>

<!-- BETTER — pipe only runs when users reference changes -->
<li *ngFor="let user of users | filterActive">{{ user.name }}</li>

Avoiding Memory Leaks with Unsubscription

Subscriptions that are not cleaned up when a component is destroyed continue to run in the background, consuming memory and potentially causing errors. Always unsubscribe from long-lived Observables when a component is destroyed:

Method 1 — Using takeUntil (Recommended)


import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

export class DataComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();

  ngOnInit() {
    this.dataService.getUpdates().pipe(
      takeUntil(this.destroy$)
    ).subscribe(data => this.data = data);
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Method 2 — Using the Async Pipe (Simplest)


// No manual unsubscription needed — async pipe handles it automatically
export class AutoCleanComponent {
  data$ = this.dataService.getUpdates();   // Observable, not subscribed

  constructor(private dataService: DataService) {}
}

<div *ngIf="data$ | async as data">
  {{ data.name }}
</div>

Bundle Size Optimization

Production Build

Always use a production build for deployment. The production build enables Ahead-of-Time (AOT) compilation, tree shaking (removes unused code), and minification:


ng build --configuration production

Analyzing Bundle Size

The @angular-devkit/build-angular package provides bundle analysis. Install source-map-explorer to visualize what is inside the bundle:


npm install -g source-map-explorer

ng build --source-map
source-map-explorer dist/my-app/*.js

Tree Shaking and Unused Imports

Angular's build process removes code that is imported but never used. To benefit from tree shaking, avoid importing entire libraries when only one function is needed:


// INEFFICIENT — imports the entire lodash library
import * as _ from 'lodash';
const result = _.chunk(array, 3);

// BETTER — import only the function needed
import chunk from 'lodash/chunk';
const result = chunk(array, 3);

Virtual Scrolling for Long Lists

Rendering thousands of DOM elements for a large list causes serious performance problems. Angular CDK's virtual scroll renders only the items currently visible in the viewport:


// Install Angular CDK
npm install @angular/cdk

// app.module.ts
import { ScrollingModule } from '@angular/cdk/scrolling';

@NgModule({
  imports: [ScrollingModule]
})

<!-- Without virtual scroll: all 10,000 items rendered at once -->
<ul>
  <li *ngFor="let item of tenThousandItems">{{ item.name }}</li>
</ul>

<!-- With virtual scroll: only ~20 visible items rendered at a time -->
<cdk-virtual-scroll-viewport itemSize="50" style="height: 400px;">
  <div *cdkVirtualFor="let item of tenThousandItems">
    {{ item.name }}
  </div>
</cdk-virtual-scroll-viewport>

Debouncing User Input

Responding immediately to every keystroke (such as making an API call on each key press) wastes resources. Debouncing delays the response until the user stops typing:


import { FormControl } from '@angular/forms';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';

export class SearchComponent implements OnInit {
  searchControl = new FormControl('');

  ngOnInit() {
    this.searchControl.valueChanges.pipe(
      debounceTime(400),          // Wait 400ms after the last keystroke
      distinctUntilChanged()      // Only emit if the value actually changed
    ).subscribe(value => {
      // API call happens at most once every 400ms
      this.performSearch(value || '');
    });
  }
}

Performance Optimization Quick Reference


Technique                | What It Does                          | Impact
-------------------------|---------------------------------------|----------
OnPush detection         | Skip re-checking unchanged components | High
trackBy in ngFor         | Reuse unchanged DOM elements          | High
Lazy loading modules     | Reduce initial bundle size            | High
Async pipe               | Auto-unsubscribe, no manual cleanup   | Medium
Virtual scrolling        | Render only visible list items        | High
Pure pipes               | Avoid repeated computations           | Medium
Production build         | AOT, tree shaking, minification       | High
Image lazy loading       | Load images only when near viewport   | Medium
Debouncing input         | Reduce API calls from user typing     | Medium

Summary

Angular performance optimization focuses on reducing unnecessary rendering, minimizing bundle sizes, and avoiding resource waste. The OnPush change detection strategy is the highest-impact optimization — it skips re-rendering components unless their inputs change. trackBy in *ngFor prevents the DOM from being fully rebuilt when a list updates. Lazy loading routes and modules reduces the initial bundle downloaded by the browser. Virtual scrolling from Angular CDK handles massive lists efficiently. The async pipe prevents memory leaks by automatically unsubscribing. Always deploy with a production build to benefit from AOT compilation, tree shaking, and minification.

Leave a Comment

Your email address will not be published. Required fields are marked *