Skip to content
Angular best practices 4 min read

Performance Best Practices

Angular performance is rarely about a single magic flag — it is the cumulative result of doing many small things right. The two questions that matter most are: how often does change detection run, and how much JavaScript ships to the browser. Get those under control with OnPush, signals, lazy loading, stable track expressions, and bundle budgets, and your app stays fast as it grows. This page consolidates the techniques that deliver the biggest wins.

Minimize change detection with OnPush and signals

By default Angular re-checks every component on every event in the app. ChangeDetectionStrategy.OnPush narrows that down: a component is checked only when an input changes by reference, an event fires inside it, or a bound signal or async pipe emits. Combined with signals — which notify Angular precisely when their value changes — this is the single most effective lever for runtime performance.

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

@Component({
  selector: 'app-price-table',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Items: {{ count() }}</p>
    <p>Total: {{ total() | currency }}</p>
    <button (click)="add(19.99)">Add</button>
  `,
})
export class PriceTableComponent {
  private readonly prices = signal<number[]>([]);
  readonly count = computed(() => this.prices().length);
  readonly total = computed(() => this.prices().reduce((a, b) => a + b, 0));

  add(price: number): void {
    this.prices.update((list) => [...list, price]); // new array → reference change
  }
}

computed() values are memoized — they recalculate only when a dependency changes — so derived state in templates costs almost nothing. Avoid calling methods or getters directly in bindings; they run on every change-detection pass.

Tip: Modern Angular supports zoneless change detection via provideZonelessChangeDetection(). With signals driving updates you can drop zone.js entirely, shrinking the bundle and removing the overhead of patched async APIs.

Always use a stable track in @for

The @for block requires a track expression, and choosing the right one is critical. With a stable key Angular reuses existing DOM nodes when the list changes; without one it tears down and rebuilds rows, destroying performance and resetting element state like focus and scroll.

@for (user of users(); track user.id) {
  <app-user-row [user]="user" />
} @empty {
  <p>No users.</p>
}

Track by a unique, stable identifier (an id), never by $index for data that can be reordered, inserted, or removed mid-list.

Lazy load routes and defer heavy UI

Ship less JavaScript up front by splitting the app along route boundaries. Lazy loadComponent/loadChildren routes are fetched only when the user navigates to them.

import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', loadComponent: () => import('./home/home.component').then((m) => m.HomeComponent) },
  { path: 'admin', loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES) },
];

For expensive pieces inside a view — charts, comment threads, below-the-fold widgets — use the @defer block to delay loading until a trigger fires.

@defer (on viewport) {
  <app-analytics-chart [data]="metrics()" />
} @placeholder {
  <div class="skeleton">Loading chart…</div>
} @loading (minimum 200ms) {
  <app-spinner />
}
TriggerLoads when
on idleThe browser is idle (default)
on viewportThe placeholder scrolls into view
on interactionThe user clicks or focuses the placeholder
on hoverThe pointer hovers the placeholder
on timer(2s)After the specified delay

Enforce bundle budgets

Performance regressions creep in silently. Wire budgets into angular.json so the build fails — or warns — when bundles grow past a threshold.

"budgets": [
  { "type": "initial", "maximumWarning": "500kB", "maximumError": "1mB" },
  { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
]

Output:

▲ [WARNING] bundle initial exceeded maximum budget.
  Budget 500.00 kB was not met by 64.20 kB with a total of 564.20 kB.

Pair budgets with a source-map analysis (ng build --stats-json then esbuild-visualizer or source-map-explorer) to find what is bloating the bundle — usually a heavy dependency imported eagerly.

Other high-impact wins

A few more techniques pay off consistently:

  • Server-side rendering / hydration. provideClientHydration() reuses server-rendered DOM instead of re-rendering, improving first paint and Core Web Vitals.
  • NgOptimizedImage. The ngSrc directive adds automatic lazy loading, srcset generation, and priority hints for LCP images.
  • takeUntilDestroyed(). Tear down subscriptions automatically to prevent memory leaks that slow long-lived apps.
import { Component, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({ selector: 'app-ticker', standalone: true, template: `{{ tick }}` })
export class TickerComponent {
  tick = 0;
  constructor() {
    interval(1000)
      .pipe(takeUntilDestroyed(inject(DestroyRef)))
      .subscribe(() => (this.tick++));
  }
}

Best Practices

  • Enable ChangeDetectionStrategy.OnPush everywhere and drive updates with signals and immutable data.
  • Replace template method calls and getters with memoized computed() signals.
  • Provide a stable, unique track in every @for; never track reorderable lists by $index.
  • Lazy load feature routes with loadComponent/loadChildren and defer heavy in-view UI with @defer.
  • Define initial and component-style budgets in angular.json and analyze bundles regularly.
  • Use NgOptimizedImage (ngSrc) for images and enable hydration with provideClientHydration().
  • Always unsubscribe with takeUntilDestroyed() or the async pipe to avoid leaks.
Last updated June 14, 2026
Was this helpful?