---
name: angular-performance
description: Diagnose and fix Angular performance — slow change detection, ExpressionChangedAfterItHasBeenChecked, big bundles, slow initial load, janky lists/tables, memory leaks from subscriptions, and poor Core Web Vitals. Use this whenever the user says an Angular app is slow, asks about OnPush, zoneless, signals for performance, @defer, hydration, lazy loading, trackBy/track, CDK virtual scroll, bundle budgets, or profiling with Angular DevTools. Also apply it when reviewing Angular code that uses function calls or pipes with heavy logic in templates, `*ngFor` without track, default change detection, or manual subscriptions.
metadata:
  technology: Angular
  type: performance
---

# Angular Performance

Angular performance problems cluster into three buckets: **change detection doing too much work**, **too much JavaScript shipped**, and **templates doing work on every check**. Identify the bucket before proposing a fix.

## 1. Measure

| Symptom | Tool | Metric |
|---|---|---|
| Interactions feel slow | **Angular DevTools → Profiler** (record, see CD cycles and per-component time) | Number of change-detection runs per interaction, longest component |
| Slow load | Lighthouse, `ng build --stats-json` + `esbuild` analyzer / `source-map-explorer` | LCP, initial bundle size, number of lazy chunks |
| Jank while scrolling/typing | Chrome Performance panel | Long tasks, time in `tick()` / `refreshView` |
| Memory growth across routes | DevTools heap snapshots | Retained components, subscriptions |
| Layout shift | Lighthouse CLS | Images/fonts without dimensions, late-loaded content |

Capture a baseline with numbers before changing anything. Profile a production build (`ng build`, `ng serve --configuration production`), not dev.

## 2. Change detection — the biggest lever

Work down this list:

1. **`ChangeDetectionStrategy.OnPush` on every component.** Default CD checks the entire tree on every event; OnPush checks a component only when an input reference changes, an event fires inside it, or a signal it reads changes. Add it, then fix what breaks — breakage means something is mutating objects in place.
2. **Signals for all component state.** With signals, Angular marks only the exact components that read a changed signal. Convert `@Input()` → `input()`, fields → `signal()`, derived → `computed()`.
3. **Go zoneless.** `provideZonelessChangeDetection()` removes zone.js: no more CD on every `setTimeout`, every `fetch`, every mouse move. Requires OnPush + signals everywhere; third-party libs must not rely on zone. This is the end state for new apps.
4. **Kill template function calls.** `{{ formatPrice(item.price) }}` runs on every CD cycle. Replace with a pure pipe (memoised per input) or a `computed()`.
5. **Pure pipes over impure.** Never `pure: false` unless the pipe genuinely depends on external mutable state; use signals instead.
6. **`@for` with `track`** on a stable id. `track $index` on mutable lists causes full re-renders and DOM churn; `track item.id` reuses nodes.
7. **Detach expensive, rarely-changing subtrees** — with signals this is rarely needed; `ChangeDetectorRef.detach()` is a last resort.

`ExpressionChangedAfterItHasBeenChecked`: it means state changed *during* a CD pass — usually a child writing to a parent in `ngAfterViewInit`, or an `effect()` writing signals. Fix the data flow (compute upward with `computed`, emit through `output()`), don't wrap in `setTimeout`.

## 3. Initial load and bundle size

- **Lazy load every feature route** (`loadChildren`/`loadComponent`); the initial bundle should contain the shell, auth, and the landing route only.
- **`@defer`** for heavy components below the fold or behind interaction: `@defer (on viewport) { <app-chart/> } @placeholder { <div class="skeleton"/> }`. Triggers: `on idle`, `on viewport`, `on interaction`, `on hover`, `on timer`, `when <condition>`. Add `@loading` and `@error` blocks.
- **Bundle budgets** in `angular.json` (`initial` warning ~500 kB, error ~1 MB; adjust to the app) and fail CI on breach.
- **Analyse** with `ng build --stats-json` then esbuild's analyzer: look for moment.js (→ `date-fns`/Temporal), full lodash, RxJS operator misuse, whole icon fonts, Material modules imported at root instead of per component.
- **Standalone imports per component** pull in only what each template uses — another reason to leave NgModules.
- **Fonts**: preload one critical font, `font-display: swap`, subset. **Images**: `NgOptimizedImage` (`ngSrc`, `priority` on the LCP image, `width`/`height`, `sizes`) — it enforces best practices.
- **Preconnect** to API and CDN origins in `index.html`.
- **Third-party scripts** deferred and measured; they're frequently the entire INP problem.

## 4. SSR, hydration, and Core Web Vitals

- `@angular/ssr` with `provideClientHydration()` for public pages — hydration reuses server DOM instead of re-rendering; verify no hydration mismatch warnings.
- **Incremental hydration** (`withIncrementalHydration()` + `@defer (hydrate on viewport)`) so non-critical islands stay static until needed.
- **Event replay** (`withEventReplay()`) so clicks during hydration aren't lost.
- Avoid `document`/`window` access outside `afterNextRender`/`isPlatformBrowser` guards — it breaks SSR and forces client-only rendering.
- Prerender static routes at build time (`prerender` in `angular.json` / `getPrerenderParams`).

## 5. Lists and tables

- **CDK Virtual Scroll** (`cdk-virtual-scroll-viewport` + `*cdkVirtualFor`) for > ~100 rows; `itemSize` fixed when possible.
- Server-side pagination/sorting/filtering for large datasets; never load 10k rows to filter client-side.
- Row components `OnPush` with signal inputs; no functions in row templates.
- Material Table: virtual scroll via CDK or paginate; avoid `MatSort`/`MatPaginator` on huge client arrays.
- Debounce filter inputs (RxJS `debounceTime(300)` + `distinctUntilChanged` + `switchMap`), but update the input echo immediately.

## 6. RxJS-related leaks and waste

- Every manual `subscribe()` needs `takeUntilDestroyed()` (or `toSignal`, which cleans up). Missing teardown is the #1 Angular memory leak.
- `shareReplay({ bufferSize: 1, refCount: true })` for shared HTTP results; without `refCount` the source lives forever.
- `switchMap` for typeahead/route param changes (cancels stale requests); `exhaustMap` for submit.
- Don't create Observables in templates or getters — each CD cycle creates a new stream and `AsyncPipe` resubscribes.
- Router `events` subscriptions in many components → centralise in one service exposing signals.

## 7. Runtime hygiene

- Heavy synchronous work (sorting, parsing, crypto) → Web Worker (`ng generate web-worker`).
- `runOutsideAngular` for high-frequency events (scroll, mousemove, resize) when still on zone.js.
- `afterRender`/`afterNextRender` for DOM measurement instead of `setTimeout(0)`.
- `content-visibility: auto` on long off-screen sections; `will-change` sparingly.
- Avoid `ViewEncapsulation.None` styles with deep selectors — global style recalcs get expensive.

## 8. Report format

```
## Baseline
<metric>: <value> (production build, device, network)

## Bucket
Change detection / Bundle / Template work / Memory

## Findings (impact order)
1. <problem> — <evidence from DevTools profiler / analyzer> — est. gain

## Fixes
Minimal diff each, with before/after expectation

## Verify
Same measurement repeated
```

## Anti-patterns to reject

- Default change detection on new components.
- Function calls or `new Date()` / `JSON.stringify` in templates.
- `@for` without `track`, or `track $index` on reorderable data.
- `setTimeout` to dodge `ExpressionChangedAfterItHasBeenChecked`.
- `subscribe()` in `ngOnInit` with no teardown.
- `SharedModule` imported into every lazy chunk (duplicates code into each).
- `pure: false` pipes for formatting.
- Rendering all rows of a large dataset "because virtual scroll is complicated".
