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.
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
}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.
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:
// 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),
},
];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:
<!-- 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>// 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:
<!-- 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:
// ❌ 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:
npm install @angular/cdk
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
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$;
}
}// 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:
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:
// 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;
});
}
}// 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
Angular DevTools Profiler — record and analyze change detection cycles
Chrome DevTools Performance tab — CPU flame chart, layout/paint events
Lighthouse — automated PWA/performance score (run from DevTools)
ng build --stats-json + webpack-bundle-analyzer for bundle analysis
Core Web Vitals report in Google Search Console for real-user data