10 Angular Performance Tips Every Developer Should Know
Boost your Angular app speed with 10 proven performance tips! Learn OnPush, lazy loading, trackBy, and more with simple analogies and real-world examples. Updated for Angular 19+ with modern syntax.
Why Performance Matters š
A slow Angular app = frustrated users = lost business. Studies show users abandon pages that take more than 3 seconds to load.
Good news: Most performance issues have simple fixes. Let's dive into 10 tips that will make your app fly!
š¢ Note: This guide is updated for Angular 19/21+ which uses standalone components by default and the new
@for/@ifcontrol flow syntax. Caveats for older versions are included.
1. Use OnPush Change Detection
Analogy: š Instead of checking every room every second, only check rooms when someone rings the doorbell.
What it does: Angular normally checks ALL components on every event. OnPush tells Angular: "Only check me if my inputs change."
Angular 19+ (Standalone - Recommended)
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<h2>{{ user().name }}</h2>`
})
export class UserCardComponent {
// Signal-based input (Angular 17+)
user = input.required<User>();
}
Legacy Angular (with NgModules)
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<h2>{{ user.name }}</h2>`
})
export class UserCardComponent {
@Input() user!: User;
}
When OnPush triggers change detection:
- ā
@Input()orinput()reference changes - ā Event handler fires in the component
- ā Async pipe receives new value
- ā
markForCheck()is called manually - ā NOT when parent runs change detection
- ā NOT when internal property changes (use signals!)
ā ļø Caveat: With OnPush, mutating objects/arrays won't trigger updates. Always create NEW references!
// ā Won't trigger update with OnPush
this.items.push(newItem);
// ā
Creates new array reference
this.items = [...this.items, newItem];
// ā
Even better - use signals!
this.items.update(arr => [...arr, newItem]);
Impact: š„š„š„š„š„ Can reduce change detection cycles by 90%+ in large apps.
2. Use track with @for (or trackBy with *ngFor)
Analogy: š Instead of throwing away all books and buying new ones, just update the changed books.
What it does: Helps Angular identify which items changed, added, or removed.
Angular 19+ (New Control Flow - Recommended)
@Component({
template: `
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
} @empty {
<p>No items found</p>
}
`
})
export class ListComponent {
items = signal<Item[]>([]);
}
š Good news: In Angular 17+,
trackis required in@for- you can't forget it!
Legacy Angular (NgModule + *ngFor)
// ā Without trackBy - rebuilds entire list
<div *ngFor="let item of items">{{ item.name }}</div>
// ā
With trackBy - only updates changed items
<div *ngFor="let item of items; trackBy: trackById">
{{ item.name }}
</div>
// In component
trackById(index: number, item: Item): number {
return item.id;
}
Track Options:
// Track by object property (most common)
@for (user of users(); track user.id) { ... }
// Track by index (use when items have no unique ID)
@for (item of items(); track $index) { ... }
// Track by the item itself (for primitives)
@for (name of names(); track name) { ... }
ā ļø Caveat: Using track $index is less efficient than a unique ID because inserting/removing items shifts all indices.
Impact: š„š„š„š„ Massive improvement for lists with 100+ items.
3. Lazy Load Routes (Standalone Components)
Analogy: š Don't cook the entire menu when customer only ordered pizza. Cook dishes as ordered.
What it does: Loads components only when user navigates to them.
Angular 19+ (Standalone - Recommended)
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component')
.then(m => m.DashboardComponent)
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes')
.then(m => m.ADMIN_ROUTES)
},
{
path: 'profile',
loadComponent: () => import('./profile/profile.component')
.then(m => m.ProfileComponent),
// Prefetch when link is visible
data: { preload: true }
}
];
Legacy Angular (NgModules)
// app-routing.module.ts
const routes: Routes = [
{
path: 'dashboard',
loadChildren: () => import('./dashboard/dashboard.module')
.then(m => m.DashboardModule)
}
];
Key Differences:
| Feature | Standalone (19+) | NgModules (Legacy) |
|---|---|---|
| Load component | loadComponent | Not available |
| Load routes | loadChildren ā routes | loadChildren ā module |
| File size | Smaller bundles | Larger (module overhead) |
| Tree-shaking | Better | Limited |
ā ļø Caveat: If you're migrating from NgModules, use ng generate @angular/core:standalone to automate conversion.
Impact: š„š„š„š„š„ Initial bundle can shrink by 50-80%.
4. Use Pure Pipes Instead of Methods
Analogy: š§® A calculator that remembers answers vs. recalculating every time.
What it does: Pipes cache results. Methods in templates run on EVERY change detection cycle.
Angular 19+ (Standalone Pipe)
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'dateFormat',
standalone: true,
pure: true // Default - can omit
})
export class DateFormatPipe implements PipeTransform {
transform(date: Date | string): string {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}).format(new Date(date));
}
}
// Usage in component
@Component({
imports: [DateFormatPipe],
template: `<p>{{ createdAt | dateFormat }}</p>`
})
Method vs Pipe:
// ā Method - runs 100+ times during change detection
<p>{{ formatDate(date) }}</p>
// ā
Pure pipe - caches result, runs only when input changes
<p>{{ date | dateFormat }}</p>
// ā
Even better - use computed signal!
<p>{{ formattedDate() }}</p>
// In component
date = signal(new Date());
formattedDate = computed(() =>
new Intl.DateTimeFormat('en').format(this.date())
);
ā ļø Caveat: Pure pipes only re-execute when the INPUT REFERENCE changes. For objects/arrays, create new references.
Impact: š„š„š„ Eliminates hundreds of redundant calculations.
5. Unsubscribe from Observables
Analogy: šæ Don't leave the tap running when you're done. Memory leaks flood your app!
What it does: Prevents memory leaks and ghost subscriptions that accumulate over time.
Method 1: takeUntilDestroyed (Angular 16+ - Recommended)
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({...})
export class UserComponent {
private destroyRef = inject(DestroyRef);
constructor() {
// Option A: In constructor with inject context
this.userService.getUser().pipe(
takeUntilDestroyed()
).subscribe(user => console.log(user));
}
ngOnInit() {
// Option B: Outside constructor, pass DestroyRef
this.updates$.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(update => this.handleUpdate(update));
}
}
Method 2: Async Pipe (Best for templates!)
@Component({
template: `
@if (user$ | async; as user) {
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
} @else {
<p>Loading...</p>
}
`
})
export class UserComponent {
user$ = this.userService.getUser();
}
Method 3: toSignal (Angular 16+ - For Signals)
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: `
@if (user(); as user) {
<h1>{{ user.name }}</h1>
}
`
})
export class UserComponent {
// Converts Observable to Signal, auto-unsubscribes
user = toSignal(this.userService.getUser(), {
initialValue: null
});
}
Comparison:
| Method | Auto-cleanup | Works with | Best for |
|---|---|---|---|
| takeUntilDestroyed | ā Yes | Side effects | Service calls |
| async pipe | ā Yes | Templates | Display data |
| toSignal | ā Yes | Signals | Modern apps |
| Manual unsubscribe | ā Manual | Everything | Legacy |
ā ļø Caveat: takeUntilDestroyed() without argument only works in injection context (constructor). Outside constructor, pass DestroyRef.
Impact: š„š„š„š„ Prevents memory leaks that accumulate and slow your app.
6. Virtual Scrolling for Large Lists
Analogy: š± Your phone contact list doesn't load 1000 contacts at once. It only renders the visible ones.
What it does: Only renders items currently visible in the viewport. Scrolling dynamically loads/unloads items.
Angular 19+ with CDK Virtual Scrolling
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
standalone: true,
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport
itemSize="72"
class="h-[500px] w-full">
<div *cdkVirtualFor="let item of items; trackBy: trackById"
class="h-[72px] p-4 border-b">
<h3>{{ item.name }}</h3>
<p>{{ item.description }}</p>
</div>
</cdk-virtual-scroll-viewport>
`
})
export class VirtualListComponent {
items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
description: `Description for item ${i}`
}));
trackById = (index: number, item: any) => item.id;
}
ā ļø Important: Virtual scroll uses *cdkVirtualFor, NOT the new @for syntax yet!
When to use virtual scrolling:
ā ļø Caveats:
itemSizemust be fixed or useautosizedirective (experimental)- All items must have the same height for best performance
- Images inside virtual scroll need lazy loading
Impact: š„š„š„š„š„ Render 10,000 items at 60fps vs. 100 items sluggishly.
7. Preload Lazy Routes Strategically
Analogy: šæ Start making popcorn when customer enters lobby, not when they order.
What it does: Loads likely-needed routes in background after initial page load completes.
Angular 19+ Preloading Strategies
import {
PreloadAllModules,
NoPreloading,
provideRouter,
withPreloading
} from '@angular/router';
// app.config.ts
export const appConfig = {
providers: [
provideRouter(
routes,
withPreloading(PreloadAllModules) // Preload everything
)
]
};
Custom Selective Preloading (Recommended)
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class SelectivePreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
// Only preload routes marked with data.preload = true
if (route.data?.['preload']) {
console.log(`Preloading: ${route.path}`);
return load();
}
return of(null);
}
}
// Routes configuration
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard.component'),
data: { preload: true } // Will preload
},
{
path: 'admin',
loadComponent: () => import('./admin.component'),
// No preload flag - loads only when navigated
}
];
// app.config.ts
provideRouter(routes, withPreloading(SelectivePreloadStrategy))
Preloading Options:
| Strategy | What it does | Use when |
|---|---|---|
| NoPreloading | Nothing preloaded | Slow connections |
| PreloadAllModules | Everything after load | Fast connections |
| Custom selective | Only marked routes | Most apps |
| QuicklinkStrategy | Visible links only | Large apps |
ā ļø Caveat: Don't preload admin/rarely-used routes. Wastes bandwidth on mobile.
Impact: š„š„š„ Instant navigation to preloaded routes.
8. Use Web Workers for Heavy Computation
Analogy: šØāš³ Hire a separate chef for complex dishes so the main chef stays responsive to customers.
What it does: Offloads CPU-heavy tasks to a background thread, keeping UI responsive.
Creating a Web Worker (Angular CLI)
ng generate web-worker heavy-calc
Using Web Worker
// heavy-calc.worker.ts
addEventListener('message', ({ data }) => {
// Heavy computation happens here
const result = processLargeDataset(data);
postMessage(result);
});
function processLargeDataset(data: number[]): number {
// Simulate heavy work
return data.reduce((sum, n) => sum + Math.sqrt(n), 0);
}
// component.ts
@Component({...})
export class AnalyticsComponent {
result = signal<number | null>(null);
isProcessing = signal(false);
processData(data: number[]) {
if (typeof Worker !== 'undefined') {
this.isProcessing.set(true);
const worker = new Worker(
new URL('./heavy-calc.worker', import.meta.url)
);
worker.postMessage(data);
worker.onmessage = ({ data: result }) => {
this.result.set(result);
this.isProcessing.set(false);
worker.terminate(); // Clean up!
};
worker.onerror = (error) => {
console.error('Worker error:', error);
this.isProcessing.set(false);
};
} else {
// Fallback for environments without Web Workers
this.result.set(this.processSync(data));
}
}
}
Good use cases for Web Workers:
- š Processing large datasets (1000+ items)
- š Encryption/decryption
- š Complex calculations (financial, scientific)
- š¼ļø Image processing
- š Parsing large files (CSV, JSON)
ā ļø Caveats:
- Web Workers can't access DOM
- Data is copied (not shared) - large transfers have overhead
- Not available in SSR/server environments
Impact: š„š„š„ UI stays responsive during 5-second calculations.
9. Optimize Images with NgOptimizedImage
Analogy: š· Don't load billboard-sized images for thumbnail slots. Right size, right time.
What it does: Automatic lazy loading, responsive srcset, proper sizing, LCP optimization.
Angular 19+ (Standalone)
import { NgOptimizedImage } from '@angular/common';
@Component({
standalone: true,
imports: [NgOptimizedImage],
template: `
<!-- Hero image - priority loading for LCP -->
<img ngSrc="/images/hero.jpg"
width="1200"
height="600"
priority
placeholder />
<!-- Regular images - lazy loaded -->
@for (product of products(); track product.id) {
<img [ngSrc]="product.image"
width="400"
height="300"
loading="lazy"
[alt]="product.name" />
}
<!-- Fill mode for flexible sizing -->
<div class="relative w-full h-64">
<img ngSrc="/images/banner.jpg"
fill
class="object-cover" />
</div>
`
})
export class ProductsComponent {
products = signal<Product[]>([]);
}
Key Features:
| Feature | What it does | Benefit |
|---|---|---|
priority | Preloads image | Faster LCP |
loading="lazy" | Loads when visible | Saves bandwidth |
fill | Fills container | Flexible sizing |
placeholder | Blur placeholder | Better UX |
srcset | Auto-generated | Right size per device |
ā ļø Caveats:
widthandheightare required (prevents layout shift)- Only ONE image should have
priority(usually hero/LCP) - Use
fillfor responsive images without fixed dimensions - Configure image loader for CDN optimization
Setting up Image Loader (for CDN):
// app.config.ts
import { provideImgixLoader } from '@angular/common';
export const appConfig = {
providers: [
provideImgixLoader('https://your-cdn.imgix.net')
]
};
Impact: š„š„š„š„ Images load 50% faster, better Core Web Vitals.
10. Use Signals for Reactive State
Analogy: š” Signals are like radio towers - they only broadcast when something changes, not constantly polling.
What it does: Fine-grained reactivity without Zone.js overhead. Only affected parts update.
Modern Signal-Based Component (Angular 19+)
import {
Component, signal, computed, effect,
input, output, model
} from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter">
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<p>Even? {{ isEven() ? 'Yes' : 'No' }}</p>
<button (click)="decrement()">-</button>
<button (click)="increment()">+</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// Input signal (from parent)
initialValue = input(0);
// Two-way binding signal
count = model(0);
// Computed signals (auto-update)
double = computed(() => this.count() * 2);
isEven = computed(() => this.count() % 2 === 0);
// Output event
countChanged = output<number>();
constructor() {
// Effect for side effects
effect(() => {
console.log(`Count changed to: ${this.count()}`);
localStorage.setItem('count', String(this.count()));
});
}
increment() {
this.count.update(n => n + 1);
this.countChanged.emit(this.count());
}
decrement() {
this.count.update(n => n - 1);
}
reset() {
this.count.set(this.initialValue());
}
}
Signal API Cheat Sheet:
| API | Purpose | Example |
|---|---|---|
signal() | Writable state | count = signal(0) |
computed() | Derived state | double = computed(() => count() * 2) |
effect() | Side effects | effect(() => save(data())) |
input() | Component input | name = input<string>() |
input.required() | Required input | id = input.required<number>() |
output() | Component output | clicked = output<void>() |
model() | Two-way binding | value = model('') |
toSignal() | Observable ā Signal | data = toSignal(obs$) |
toObservable() | Signal ā Observable | data$ = toObservable(sig) |
ā ļø Caveats:
- Signals require
()to read:count()notcount - Don't call signals in loops without memoization
effect()runs in injection context by default- Effects can't be synchronously triggered
Impact: š„š„š„š„ Better performance than Zone.js change detection.
Performance Decision Tree
Angular Version Compatibility
Quick Reference Cheat Sheet
| Tip | Angular 19+ Syntax | Legacy Syntax | Impact |
|---|---|---|---|
| Change Detection | ChangeDetectionStrategy.OnPush | Same | āāāāā |
| List Tracking | @for (x of items; track x.id) | *ngFor + trackBy | āāāā |
| Lazy Loading | loadComponent: () => import(...) | loadChildren: () => import(...) | āāāāā |
| Pipes | standalone: true in pipe | Declare in NgModule | āāā |
| Unsubscribe | takeUntilDestroyed() | Subject + takeUntil | āāāā |
| Virtual Scroll | *cdkVirtualFor | Same | āāāāā |
| Preloading | withPreloading() | RouterModule.forRoot(routes, {}) | āāā |
| Web Workers | Same | Same | āāā |
| Images | NgOptimizedImage | Same | āāāā |
| Reactivity | Signals (signal(), computed()) | RxJS + async pipe | āāāā |
Common Mistakes to Avoid
ā Calling methods in templates
<!-- Wrong - runs on every check -->
<p>{{ calculateTotal() }}</p>
<!-- Right - use computed signal -->
<p>{{ total() }}</p>
ā Forgetting track in @for
<!-- Wrong - won't compile in Angular 17+! -->
@for (item of items()) {
<div>{{ item.name }}</div>
}
<!-- Right -->
@for (item of items(); track item.id) {
<div>{{ item.name }}</div>
}
ā *Using ngFor in new projects
<!-- Legacy - avoid in Angular 17+ -->
<div *ngFor="let item of items">...</div>
<!-- Modern -->
@for (item of items(); track item.id) {
<div>{{ item.name }}</div>
}
ā Mutating arrays with OnPush
// Wrong - won't trigger update!
this.items().push(newItem);
// Right - create new reference
this.items.update(arr => [...arr, newItem]);
Summary: Start With These 3
If you're overwhelmed, start with these high-impact, low-effort changes:
- Use
@forwithtrack- Required in Angular 17+, big performance win - Use signals +
toSignal()- Cleaner code, better performance - Lazy load with
loadComponent- Biggest bundle size reduction
Then progressively add OnPush, virtual scrolling, and other optimizations.
Pro tip: Use Chrome DevTools Performance tab and Angular DevTools to profile before and after!