Skip to main content

Angular Performance Optimization

Performance Matters

Applications must remain responsive under load. Slow UIs frustrate users and reduce engagement. Angular's change detection can become a bottleneck in large apps. Use OnPush strategy, lazy loading, and proper trackBy functions to maintain 60fps interactions even with thousands of items displayed.

Overview

This guide covers Angular performance optimization strategies including change detection optimization, lazy loading, bundle optimization, and profiling techniques. Performance optimization should be data-driven - always measure before and after changes to verify improvements. Premature optimization wastes time; profile first to identify actual bottlenecks.


Core Principles

  1. OnPush Change Detection: Reduce unnecessary change detection cycles
  2. Lazy Load Routes: Load features on demand
  3. trackBy in ngFor: Optimize list rendering
  4. Pure Pipes: Avoid expensive computations on every change detection
  5. Bundle Optimization: Code split and tree shake
  6. Measure First: Profile before optimizing

Change Detection Optimization

Angular's change detection is its mechanism for keeping the view synchronized with component state. By default, Angular uses the Default strategy, which checks every component whenever any event occurs anywhere in the application (mouse clicks, HTTP responses, timers, etc.). For small applications this works fine, but for large applications with hundreds of components, this becomes a performance bottleneck.

OnPush change detection is the primary optimization strategy. It tells Angular to only check a component when its inputs change, events fire from the component tree, or observables emit via the async pipe. This drastically reduces the number of checks Angular must perform on each change detection cycle.

OnPush Strategy

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

@Component({
selector: 'app-payment-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // Critical
template: `
<div class="payment-card">
<span>{{ payment.id }}</span>
<span>{{ payment.amount | currency }}</span>
</div>
`
})
export class PaymentCardComponent {
@Input({ required: true }) payment!: Payment;
}

OnPush requirements:

  • Immutable inputs: Parent components must pass new object references when data changes (can't mutate objects in place)
  • Async pipe or manual triggering: For updating from observables or programmatic changes
  • Event propagation: Events from child components automatically trigger parent checks

OnPush is most effective in presentation components (components that just display data) and list items. It works seamlessly with signals, which notify Angular precisely when state changes.

Manual Change Detection

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

@Component({
selector: 'app-payment-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`
})
export class PaymentListComponent {
private cdr = inject(ChangeDetectorRef);

updatePaymentStatus(id: string, status: string): void {
// Update payment status
this.payments = this.payments.map(p =>
p.id === id ? { ...p, status } : p
);

// Manually trigger change detection
this.cdr.markForCheck();
}
}

When using OnPush, you sometimes need to manually trigger change detection for edge cases (e.g., updating data from a subscription not using async pipe, or changes from non-input sources). ChangeDetectorRef.markForCheck() schedules the component to be checked in the next change detection cycle. Always prefer async pipe or signals over manual detection when possible.


List Optimization

Rendering lists is a common performance challenge. Without optimization, Angular recreates all DOM elements whenever the array changes, even if most items haven't changed. The trackBy function solves this by telling Angular how to identify which items are the same across updates, allowing it to reuse existing DOM elements.

@Component({
selector: 'app-payment-list',
template: `
<!-- BAD: No track expression (recreates all DOM nodes on change) -->
@for (payment of payments) {
<app-payment-card [payment]="payment" />
}

<!-- GOOD: With track (reuses DOM nodes, only updates changed items) -->
@for (payment of payments; track payment.id) {
<app-payment-card [payment]="payment" />
}
`
})
export class PaymentListComponent {
payments: Payment[] = [];
}

Why track is critical:

  • Without track, Angular uses array index to track items, so any array change triggers full DOM recreation
  • With track payment.id, Angular identifies which items actually changed and only updates those DOM nodes
  • For a 1000-item list where one item updates, track means 1 DOM update instead of 1000
  • Always use unique, stable identifiers — don't use index or unstable values

The new @for block requires track to be explicit. If you omit it, Angular will warn you. This is an improvement over *ngFor where trackBy was optional (and often forgotten).


Lazy Loading

Lazy loading splits your application into chunks that load on demand rather than upfront. This significantly reduces the initial bundle size, improving time-to-interactive and first contentful paint. Users only download the code for features they actually use.

Angular's router supports lazy loading at both the route and component level using dynamic imports. Webpack automatically code-splits these imports into separate bundles. The trade-off is a slight delay when navigating to lazy-loaded routes for the first time, but this is usually imperceptible with modern networks and can be mitigated with preloading strategies.

Route-Level Lazy Loading

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
{
path: '',
redirectTo: '/payments',
pathMatch: 'full'
},
{
// GOOD: Lazy load entire feature
path: 'payments',
loadChildren: () =>
import('./features/payments/payments.routes').then(m => m.PAYMENT_ROUTES)
},
{
// GOOD: Lazy load single component
path: 'accounts',
loadComponent: () =>
import('./features/accounts/account-list.component').then(
m => m.AccountListComponent
)
}
];

Preloading Strategy

Lazy loading delays feature downloads until needed, but this creates a small navigation delay. Preloading strategies solve this by loading lazy routes in the background after the initial app loads. PreloadAllModules loads all lazy routes once the app is idle, giving you the best of both worlds - fast initial load and no navigation delays.

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withPreloading(PreloadAllModules) // Preload after initial load
)
]
};

You can also implement custom preloading strategies to preload only certain routes (e.g., based on user roles or feature flags).


Pure Pipes

Pipes transform data in templates. By default, pipes are pure - they only execute when their input reference changes. This is efficient because pure pipes can be memoized. Impure pipes (marked with pure: false) execute on every change detection cycle, which can be extremely expensive if the pipe performs heavy computations.

Creating Pure Pipes

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

// GOOD: Pure pipe (default)
@Pipe({
name: 'formatPayment',
standalone: true,
pure: true // Default value
})
export class FormatPaymentPipe implements PipeTransform {
transform(payment: Payment): string {
return `${payment.id}: ${payment.amount} ${payment.currency}`;
}
}

// AVOID: Impure pipe (runs on every change detection)
@Pipe({
name: 'filterPayments',
pure: false
})
export class FilterPaymentsPipe implements PipeTransform {
transform(payments: Payment[], status: string): Payment[] {
return payments.filter(p => p.status === status);
}
}

Pure vs impure pipes:

  • Pure pipes (default): Execute only when input reference changes - highly efficient
  • Impure pipes: Execute on every change detection cycle - use sparingly and only when necessary
  • Anti-pattern: Using impure pipes for filtering/sorting large arrays causes severe performance issues
  • Better approach: Filter/sort in component code or use memoization patterns

If you need filtering that responds to multiple inputs, use a computed signal or memoized getter in the component instead of an impure pipe.


Virtual Scrolling

For very long lists (thousands of items), even with OnPush and trackBy, rendering all DOM elements at once is expensive. Virtual scrolling solves this by only rendering items that are visible in the viewport, plus a small buffer. As users scroll, items are recycled - hidden items are removed from DOM, and newly visible items are added.

Angular CDK (Component Dev Kit) provides cdk-virtual-scroll-viewport, which handles the complex logic of viewport tracking, item positioning, and DOM recycling. This enables smooth scrolling of lists with 10,000+ items.

CDK Virtual Scroll

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

@Component({
selector: 'app-payment-list',
standalone: true,
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport
itemSize="50"
class="payment-list"
style="height: 600px;"
>
<div
*cdkVirtualFor="let payment of payments; trackBy: trackById"
class="payment-item"
>
<app-payment-card [payment]="payment" />
</div>
</cdk-virtual-scroll-viewport>
`
})
export class PaymentListComponent {
payments: Payment[] = []; // Could be 10,000+ items

trackById(index: number, payment: Payment): string {
return payment.id;
}
}

Virtual scrolling requires knowing the item height (itemSize). For variable-height items, CDK provides additional strategies. Combined with OnPush and trackBy, virtual scrolling provides excellent performance even with massive datasets.


Bundle Optimization

Bundle size directly impacts loading performance. Smaller bundles mean faster downloads, parsing, and execution. Bundle optimization involves analyzing your bundles to identify large dependencies, removing unused code through tree-shaking, and splitting code into smaller chunks.

Tree-shaking works by analyzing import statements and eliminating code that's never used. This only works with ES modules - ensure you're importing from ESM packages when available (e.g., lodash-es instead of lodash).

Analyzing Bundle Size

# Build with stats
ng build --stats-json

# Analyze with webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/stats.json

Tree Shaking

//  BAD: Imports entire lodash
import _ from 'lodash';

// GOOD: Import only needed functions
import debounce from 'lodash-es/debounce';
import throttle from 'lodash-es/throttle';

webpack-bundle-analyzer visualizes your bundle composition, showing which packages consume the most space. Use this to identify optimization opportunities - large dependencies that could be replaced with lighter alternatives, or code that's being included unnecessarily.


Optimizing Observables

Observable subscriptions, if not properly managed, cause memory leaks. Each subscription holds a reference to the observer, preventing garbage collection. Long-lived subscriptions (to services, route params, etc.) must be unsubscribed when components are destroyed.

The async pipe automatically handles subscriptions and unsubscriptions, making it the preferred approach. When manual subscriptions are necessary, use patterns like takeUntil to automatically unsubscribe on component destruction.

Subscription Management

import { Component, OnDestroy } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';

@Component({
selector: 'app-payment-list',
template: `...`
})
export class PaymentListComponent implements OnDestroy {
private destroy$ = new Subject<void>();

ngOnInit(): void {
// GOOD: Unsubscribe automatically
this.paymentService
.getPayments()
.pipe(takeUntil(this.destroy$))
.subscribe(payments => {
this.payments = payments;
});
}

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

// BETTER: Use async pipe (no manual subscription)
@Component({
selector: 'app-payment-list',
imports: [AsyncPipe],
template: `
@if (payments$ | async; as payments) {
@for (payment of payments; track payment.id) {
<app-payment-card [payment]="payment" />
}
}
`
})
export class PaymentListComponent {
payments$ = this.paymentService.getPayments();
}

The takeUntil pattern is clean and composable - pipe it onto any subscription. The destroy$ subject emits once in ngOnDestroy, causing all subscriptions piped with takeUntil(this.destroy$) to complete automatically.

However, the async pipe is still preferred - it's more concise and can't be forgotten. Use manual subscriptions only when you need to imperatively handle emitted values.


Detaching Change Detection

In rare cases, you may have components that update very infrequently but are checked constantly due to parent components. Detaching from change detection completely and manually triggering checks when needed can improve performance. Use this sparingly - it's easy to break Angular's reactivity if overused.

For Heavy Computations

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

@Component({
selector: 'app-payment-calculator',
template: `
<div>Total: {{ total }}</div>
<button (click)="calculate()">Calculate</button>
`
})
export class PaymentCalculatorComponent {
private cdr = inject(ChangeDetectorRef);
total = 0;

ngOnInit(): void {
// Detach from change detection
this.cdr.detach();
}

calculate(): void {
// Heavy computation
this.total = this.performExpensiveCalculation();

// Manually trigger detection
this.cdr.detectChanges();
}

private performExpensiveCalculation(): number {
// Expensive logic
return 0;
}
}

Detaching is an advanced technique. Prefer OnPush over detaching whenever possible - it's safer and covers most use cases.


Profiling

Performance optimization should be data-driven. Profiling tools show you where time is actually being spent, revealing bottlenecks that might not be obvious from code inspection alone. Always profile before optimizing to ensure you're addressing real problems, not imagined ones.

Angular DevTools provides Angular-specific profiling, showing component updates and change detection cycles. Chrome DevTools offers broader profiling including JavaScript execution, rendering, and network activity.

Angular DevTools

  1. Install Angular DevTools browser extension
  2. Open DevTools → Angular tab
  3. Navigate to Profiler
  4. Click "Start Recording"
  5. Interact with app
  6. Stop recording
  7. Analyze flame graph

Chrome DevTools Performance

  1. Open Chrome DevTools → Performance tab
  2. Click Record
  3. Interact with app
  4. Stop recording
  5. Look for:
    • Long tasks (>50ms)
    • Forced reflows
    • Excessive change detection cycles

The Angular DevTools profiler flame graph shows which components update during each change detection cycle and how long they take. Look for components that update unnecessarily or take excessive time. Chrome DevTools Performance tab reveals broader issues like long JavaScript tasks, forced reflows, and memory leaks.


Image Optimization

Images are often the largest assets in web applications. Optimizing images involves lazy loading (loading images only when they enter the viewport), using appropriate formats (WebP over PNG/JPEG), and serving responsive sizes. Native lazy loading is now supported in all modern browsers, making it trivial to implement.

Lazy Loading Images

@Component({
selector: 'app-payment-receipt',
template: `
<!-- Native lazy loading -->
<img
[src]="receiptUrl"
alt="Receipt"
loading="lazy"
width="600"
height="400"
/>
`
})
export class PaymentReceiptComponent {
@Input() receiptUrl!: string;
}

The native loading="lazy" attribute is the simplest approach. The browser handles intersection observation and loads images just before they enter the viewport. Always include width and height attributes to prevent layout shifts.

NgOptimizedImage (Angular 15+)

Angular's NgOptimizedImage directive adds additional optimizations beyond native lazy loading: automatic srcset generation for responsive images, preconnect hints for image CDNs, priority hints for above-the-fold images, and warnings for common mistakes like missing dimensions.

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

@Component({
selector: 'app-payment-receipt',
standalone: true,
imports: [NgOptimizedImage],
template: `
<img
[ngSrc]="receiptUrl"
alt="Receipt"
width="600"
height="400"
priority="false"
/>
`
})
export class PaymentReceiptComponent {
@Input() receiptUrl!: string;
}

Further Reading

Angular Framework Guidelines

Performance Guidelines

Comparison and Web Guidelines

External Resources


Summary

Key Takeaways

  1. OnPush change detection dramatically reduces change detection cycles
  2. trackBy in ngFor prevents unnecessary DOM recreation
  3. Lazy load routes with loadComponent and loadChildren
  4. Pure pipes only run when input reference changes
  5. Virtual scrolling for lists with 1000+ items
  6. Async pipe prevents memory leaks and manual subscriptions
  7. Bundle analysis identifies large dependencies to optimize
  8. Detach change detection for heavy computations
  9. Profile with Angular DevTools before optimizing
  10. Tree shake imports - use lodash-es not lodash

Next Steps: Review Angular Testing for testing optimized components and Performance Testing for load testing strategies.