Performance

6 min read
Rapid overview

Angular Performance Optimization

Performance optimization techniques specific to Angular applications.

Change Detection

OnPush Change Detection Strategy

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

@Component({
  selector: 'app-user',
  template: `<div>{{ user.name }}</div>`,
  changeDetection: ChangeDetectionStrategy.OnPush // Only check when inputs change
})
export class UserComponent {
  @Input() user: User;
}

Detaching Change Detection

import { ChangeDetectorRef } from '@angular/core';

export class MyComponent {
  constructor(private cdr: ChangeDetectorRef) {
    // Detach from change detection
    this.cdr.detach();
  }

  updateData() {
    // Manual change detection
    this.cdr.detectChanges();
  }
}

TrackBy for ngFor

// ❌ Bad - re-renders all items
<div *ngFor="let item of items">{{ item.name }}</div>

// ✅ Good - only re-renders changed items
<div *ngFor="let item of items; trackBy: trackById">{{ item.name }}</div>

trackById(index: number, item: Item): number {
  return item.id;
}

Signals vs Observables (performance + architecture)

High-signal interview framing:

  • Signals are synchronous, dependency-tracked values. Angular can update only consumers that read a signal.
  • Observables are async, push-based streams. Emissions often trigger change detection (via async pipe or subscription code), which can be broader work.

1) Signals are synchronous & fine-grained; Observables are async & push-based

const count = signal(0);
const doubled = computed(() => count() * 2);
  • Updating count updates only consumers of count/doubled.
  • Observables require async pipe or subscriptions, and Angular doesn’t auto-track fine-grained dependencies the same way.

2) Signals can reduce change detection cost

  • Observable emissions commonly participate in change detection.
  • Signals enable more targeted updates for local state and derived UI values.

Senior phrasing:

In data-heavy UIs (dashboards, order books), signals scale better for local state because updates are more fine-grained.

3) Use Observables for I/O and async flows; Signals for local state

Use casePrefer
HTTPObservable
WebSockets/market feedsObservable
User eventsObservable
Local component stateSignal
Derived UI stateSignal (computed)

Bridge example (observable → signal):

import { toSignal } from '@angular/core/rxjs-interop';
const price = toSignal(price$);

4) Signals eliminate subscription management for local state

Observable pitfalls:

  • manual unsubscribe patterns (takeUntil, ngOnDestroy)
  • memory leaks from forgotten teardown

Signals:

  • no subscription management for in-memory state

5) Computed signals are memoized (hidden performance win)

const filtered = computed(() =>
  orders().filter((o) => o.price > threshold())
);
  • Recomputes only when dependencies change.
  • With RxJS you often need map + distinctUntilChanged + shareReplay for similar caching behavior.

Follow-up interview question: Should signals replace observables?

Answer:

No. They solve different problems. Observables handle async streams and cancellation; signals handle local synchronous state and derived values. They complement each other.


Performance pitfalls interviewers love (and fixes)

1) Default change detection everywhere

Problem: large trees get checked frequently on async events.

Fix:

  • prefer ChangeDetectionStrategy.OnPush
  • keep inputs immutable (new references)
  • use signals for local state/derived values

2) Overusing Observables for local UI state

Problem: extra subscriptions + extra change detection + leak risk.

Fix:

  • use signals for local UI toggles/derived values

3) *ngFor without trackBy

Problem: DOM churn and poor scrolling performance.

Fix: trackBy

4) Heavy logic in templates

Problem: template calls run frequently and hide cost.

Fix:

  • precompute values (properties)
  • use computed() for derived state

5) async pipe in tight loops (careful)

Problem: multiplying work and re-render churn when used incorrectly.

Fix:

  • subscribe/convert once (e.g., toSignal)
  • render from a stable array/value

6) Missing cleanup for manual subscriptions

Fix options:

``ts import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; this.stream$.pipe(takeUntilDestroyed()).subscribe(); ``

  • prefer async pipe
  • takeUntilDestroyed() (Angular interop)

7) Running high-frequency events inside Zone

Fix:

  • ngZone.runOutsideAngular(...) for scroll/market-feed adapters
  • re-enter the zone only when you need UI updates

8) No virtual scrolling for huge lists

Fix:

  • cdk-virtual-scroll-viewport (render only what’s visible)

Lazy Loading

Module Lazy Loading

// app-routing.module.ts
const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
  },
  {
    path: 'user',
    loadChildren: () => import('./user/user.module').then(m => m.UserModule)
  }
];

Component Lazy Loading

import { Component, ViewChild, ViewContainerRef } from '@angular/core';

@Component({
  selector: 'app-lazy-wrapper',
  template: '<ng-container #container></ng-container>'
})
export class LazyWrapperComponent {
  @ViewChild('container', { read: ViewContainerRef })
  container: ViewContainerRef;

  async loadComponent() {
    const { HeavyComponent } = await import('./heavy.component');
    this.container.createComponent(HeavyComponent);
  }
}

Bundle Optimization

Tree Shaking

// ✅ Import only what you need
import { map, filter } from 'rxjs/operators';

// ❌ Don't import entire library
import * as _ from 'lodash';

Preloading Strategies

// app-routing.module.ts
import { PreloadAllModules } from '@angular/router';

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      preloadingStrategy: PreloadAllModules
    })
  ]
})
export class AppRoutingModule {}

// Custom preloading strategy
export class CustomPreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>): Observable<any> {
    return route.data?.['preload'] ? load() : of(null);
  }
}

RxJS Optimization

Unsubscribe from Observables

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

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

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

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

Async Pipe

// ✅ Good - automatic subscription management
@Component({
  template: `
    <div *ngIf="data$ | async as data">
      {{ data.name }}
    </div>
  `
})
export class MyComponent {
  data$ = this.dataService.getData();
}

Share Operators

import { shareReplay } from 'rxjs/operators';

// Share single subscription among multiple subscribers
this.data$ = this.http.get('/api/data').pipe(
  shareReplay(1)
);

Template Optimization

Pure Pipes

@Pipe({
  name: 'expensiveCalc',
  pure: true // Only recalculates when input changes
})
export class ExpensiveCalcPipe implements PipeTransform {
  transform(value: number): number {
    // Expensive calculation
    return value * Math.random();
  }
}

Avoid Function Calls in Templates

// ❌ Bad - called on every change detection
@Component({
  template: `<div>{{ getFullName() }}</div>`
})
export class BadComponent {
  getFullName() {
    return this.firstName + ' ' + this.lastName;
  }
}

// ✅ Good - computed once
@Component({
  template: `<div>{{ fullName }}</div>`
})
export class GoodComponent {
  get fullName() {
    return this.firstName + ' ' + this.lastName;
  }
}

AOT vs JIT

  • AOT: Compiles templates at build time, smaller runtime cost, better errors.
  • JIT: Compiles in the browser, faster builds during development.
  • Recommendation: Use AOT for production, JIT for rapid local iteration.

Ahead-of-Time (AOT) Compilation

// angular.json
{
  "projects": {
    "app": {
      "architect": {
        "build": {
          "configurations": {
            "production": {
              "aot": true,
              "buildOptimizer": true,
              "optimization": true
            }
          }
        }
      }
    }
  }
}

Virtual Scrolling

import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  template: `
    <cdk-virtual-scroll-viewport itemSize="50" class="viewport">
      <div *cdkVirtualFor="let item of items">
        {{ item.name }}
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: [`
    .viewport {
      height: 400px;
    }
  `]
})
export class VirtualScrollComponent {
  items = Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `Item ${i}`
  }));
}

Service Workers and PWA

# Add service worker
ng add @angular/pwa
// App module
import { ServiceWorkerModule } from '@angular/service-worker';

@NgModule({
  imports: [
    ServiceWorkerModule.register('ngsw-worker.js', {
      enabled: environment.production
    })
  ]
})
export class AppModule {}

Best Practices

  1. Use OnPush change detection when possible
  2. Lazy load feature modules
  3. Use trackBy with ngFor
  4. Unsubscribe from observables (use async pipe or takeUntil)
  5. Enable AOT compilation in production
  6. Use pure pipes for transformations
  7. Avoid function calls in templates
  8. Implement virtual scrolling for long lists
  9. Use Web Workers for heavy computations
  10. Enable service workers for caching

Measuring Performance

// Using Angular DevTools (browser extension)
// Or performance API
ngAfterViewInit() {
  performance.mark('component-rendered');
  performance.measure('init-to-render', 'component-init', 'component-rendered');
}

Build Optimization

# Production build
ng build --configuration production

# Analyze bundle size
ng build --stats-json
npx webpack-bundle-analyzer dist/stats.json

Runtime Performance Tools

  • Angular DevTools (Chrome Extension)
  • Source Map Explorer
  • Webpack Bundle Analyzer
  • Lighthouse
  • Chrome DevTools Performance Panel