Skip to content
Angular ng performance 4 min read

Performance Overview

Angular ships a powerful framework, but a fast app is the result of deliberate choices rather than defaults. Performance work in Angular splits into two distinct phases: load-time performance (how quickly the app appears and becomes interactive) and runtime performance (how smoothly it responds once running). This page surveys the bottlenecks that hurt each phase and the modern techniques — signals, the new control flow, lazy loading, and @defer — that address them, giving you a map for the deeper pages in this section.

Where Angular apps lose time

Most slowness traces back to a handful of root causes. Knowing which bucket a symptom falls into tells you which lever to pull.

SymptomPhaseLikely causePrimary fix
Slow first paint, large JS downloadLoadBig initial bundle, no code splittingLazy routes, @defer, tree-shaking
UI janks while typing or scrollingRuntimeOver-broad change detectionOnPush + signals
Long lists re-render fullyRuntimeMissing tracking in @fortrack expression
High memory, slow over timeRuntimeLeaked subscriptions/listenersCleanup, takeUntilDestroyed
Sluggish even when idleRuntimeExcessive zone-triggered cyclesSignals / zoneless

The single most impactful idea is to do less work, less often. Smaller bundles mean less to download and parse; narrower change detection means fewer components re-checked per event.

Reducing load time

Load-time cost is dominated by JavaScript size. A route that pulls in a charting library or rich-text editor should not be in the initial bundle. Angular’s two main tools here are lazy-loaded routes and the @defer block.

Lazy routes split your code at navigation boundaries using loadComponent:

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

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

@defer goes finer-grained, deferring a chunk of a single template until a trigger fires — on viewport, on interaction, or on idle:

@defer (on viewport) {
  <app-heavy-chart [data]="data()" />
} @placeholder {
  <div class="skeleton">Loading chart…</div>
} @loading (minimum 200ms) {
  <app-spinner />
}

You can confirm the split happened by inspecting the build output:

Output:

Initial chunk files | Names         |  Raw size
main-7QJ4F.js       | main          | 142.18 kB
chunk-RX9K2.js      | reports-comp  |  88.40 kB  (lazy)
chunk-Z1L8P.js      | heavy-chart   | 311.05 kB  (defer)

Set a performance budget in angular.json so the build fails when a bundle creeps over a threshold. A budget that breaks CI is far more reliable than a reminder to check.

Reducing runtime work

Runtime jank comes from Angular checking too many components on every event. The default Default change detection strategy re-checks the whole component tree; switching to OnPush restricts checks to components whose inputs changed or that emitted an event.

Signals take this further: a signal notifies Angular precisely which views depend on it, so updates touch only the affected DOM. Combined with OnPush, signal-driven components approach surgical updates.

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

@Component({
  selector: 'app-cart',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Items: {{ count() }}</p>
    <p>Total: {{ total() | currency }}</p>
    <button (click)="add()">Add item</button>
  `,
})
export class CartComponent {
  protected readonly items = signal<number[]>([]);
  protected readonly count = computed(() => this.items().length);
  protected readonly total = computed(() => this.items().reduce((a, b) => a + b, 0));

  add(): void {
    this.items.update(list => [...list, 9.99]);
  }
}

For lists, always supply a track expression in @for. Without stable identity, Angular destroys and recreates DOM nodes on every change; with it, it moves existing nodes:

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

Measure before optimizing

Guessing wastes effort. Use real tools to find the actual bottleneck before changing code.

  • Angular DevTools — the profiler tab records change-detection cycles and shows which components are expensive.
  • Chrome Performance panel — flame charts reveal long tasks and layout thrashing.
  • Lighthouse / ng build --stats-json with a bundle analyzer — pinpoints what is bloating the download.

A quick way to surface accidental over-checking is to log render counts during development, then watch the number while interacting with unrelated parts of the UI.

Best Practices

  • Profile first: confirm a bottleneck with Angular DevTools or the Chrome Performance panel before refactoring.
  • Make OnPush the default change detection strategy for every component; reserve Default for rare exceptions.
  • Prefer signals and computed over manual subscriptions to get fine-grained, automatic updates.
  • Always add a track expression to @for loops over data that changes.
  • Split heavy, route-specific, or below-the-fold code with lazy routes and @defer.
  • Enforce bundle budgets in angular.json so regressions fail the build.
  • Clean up subscriptions and listeners with takeUntilDestroyed or the DestroyRef to avoid leaks.
Last updated June 14, 2026
Was this helpful?