Munsif.
AboutExperienceProjectsAchievementsBlogsContact
HomeAboutExperienceProjectsAchievementsBlogsContact
Munsif.

Frontend Developer crafting scalable web applications with modern technologies and clean code practices.

Quick Links

  • About
  • Experience
  • Projects
  • Achievements

Connect

Ā© 2025 Shaik Munsif. All rights reserved.

Built with Next.js & Tailwind

Back to Blogs
Angular

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.

Dec 7, 202520 min read
AngularPerformanceOptimizationSignalsAngular 19

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/@if control 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() or input() 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+, track is 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:

FeatureStandalone (19+)NgModules (Legacy)
Load componentloadComponentNot available
Load routesloadChildren → routesloadChildren → module
File sizeSmaller bundlesLarger (module overhead)
Tree-shakingBetterLimited

āš ļø 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:

MethodAuto-cleanupWorks withBest for
takeUntilDestroyedāœ… YesSide effectsService calls
async pipeāœ… YesTemplatesDisplay data
toSignalāœ… YesSignalsModern apps
Manual unsubscribeāŒ ManualEverythingLegacy

āš ļø 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:

  • itemSize must be fixed or use autosize directive (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:

StrategyWhat it doesUse when
NoPreloadingNothing preloadedSlow connections
PreloadAllModulesEverything after loadFast connections
Custom selectiveOnly marked routesMost apps
QuicklinkStrategyVisible links onlyLarge 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:

FeatureWhat it doesBenefit
priorityPreloads imageFaster LCP
loading="lazy"Loads when visibleSaves bandwidth
fillFills containerFlexible sizing
placeholderBlur placeholderBetter UX
srcsetAuto-generatedRight size per device

āš ļø Caveats:

  • width and height are required (prevents layout shift)
  • Only ONE image should have priority (usually hero/LCP)
  • Use fill for 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:

APIPurposeExample
signal()Writable statecount = signal(0)
computed()Derived statedouble = computed(() => count() * 2)
effect()Side effectseffect(() => save(data()))
input()Component inputname = input<string>()
input.required()Required inputid = input.required<number>()
output()Component outputclicked = output<void>()
model()Two-way bindingvalue = model('')
toSignal()Observable → Signaldata = toSignal(obs$)
toObservable()Signal → Observabledata$ = toObservable(sig)

āš ļø Caveats:

  • Signals require () to read: count() not count
  • 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

TipAngular 19+ SyntaxLegacy SyntaxImpact
Change DetectionChangeDetectionStrategy.OnPushSame⭐⭐⭐⭐⭐
List Tracking@for (x of items; track x.id)*ngFor + trackBy⭐⭐⭐⭐
Lazy LoadingloadComponent: () => import(...)loadChildren: () => import(...)⭐⭐⭐⭐⭐
Pipesstandalone: true in pipeDeclare in NgModule⭐⭐⭐
UnsubscribetakeUntilDestroyed()Subject + takeUntil⭐⭐⭐⭐
Virtual Scroll*cdkVirtualForSame⭐⭐⭐⭐⭐
PreloadingwithPreloading()RouterModule.forRoot(routes, {})⭐⭐⭐
Web WorkersSameSame⭐⭐⭐
ImagesNgOptimizedImageSame⭐⭐⭐⭐
ReactivitySignals (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:

  1. Use @for with track - Required in Angular 17+, big performance win
  2. Use signals + toSignal() - Cleaner code, better performance
  3. 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!

Written by

Shaik Munsif

Read more articles