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.
| Symptom | Phase | Likely cause | Primary fix |
|---|---|---|---|
| Slow first paint, large JS download | Load | Big initial bundle, no code splitting | Lazy routes, @defer, tree-shaking |
| UI janks while typing or scrolling | Runtime | Over-broad change detection | OnPush + signals |
| Long lists re-render fully | Runtime | Missing tracking in @for | track expression |
| High memory, slow over time | Runtime | Leaked subscriptions/listeners | Cleanup, takeUntilDestroyed |
| Sluggish even when idle | Runtime | Excessive zone-triggered cycles | Signals / 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.jsonso 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-jsonwith 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
OnPushthe default change detection strategy for every component; reserveDefaultfor rare exceptions. - Prefer signals and
computedover manual subscriptions to get fine-grained, automatic updates. - Always add a
trackexpression to@forloops over data that changes. - Split heavy, route-specific, or below-the-fold code with lazy routes and
@defer. - Enforce bundle budgets in
angular.jsonso regressions fail the build. - Clean up subscriptions and listeners with
takeUntilDestroyedor theDestroyRefto avoid leaks.