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 dropzone.jsentirely, 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 />
}
| Trigger | Loads when |
|---|---|
on idle | The browser is idle (default) |
on viewport | The placeholder scrolls into view |
on interaction | The user clicks or focuses the placeholder |
on hover | The 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. ThengSrcdirective adds automatic lazy loading,srcsetgeneration, 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.OnPusheverywhere and drive updates with signals and immutable data. - Replace template method calls and getters with memoized
computed()signals. - Provide a stable, unique
trackin every@for; never track reorderable lists by$index. - Lazy load feature routes with
loadComponent/loadChildrenand defer heavy in-view UI with@defer. - Define
initialand component-style budgets inangular.jsonand analyze bundles regularly. - Use
NgOptimizedImage(ngSrc) for images and enable hydration withprovideClientHydration(). - Always unsubscribe with
takeUntilDestroyed()or theasyncpipe to avoid leaks.