Performance
Change detection is the one they'll dig into. Everything else here follows from understanding it.
How does change detection work?
Zone.js notices that something happened — a click, an HTTP response, a timer — and Angular then checks every component from the root down.
| What triggers it | DOM events, HTTP responses, setTimeout / setInterval, promises |
| What it does | Re-evaluates every template binding in every component |
| When it hurts | Large trees, long lists, or a function called from a template |
This is fine for most apps. When it isn't, the answer is OnPush — not disabling
zone.js.
OnPush change detection
@Component({
selector: 'app-order-row',
templateUrl: './order-row.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OrderRowComponent {
@Input() order!: Order;
}
An OnPush component is re-checked only when:
- an
@Inputreference changes - an event fires inside it
- an
asyncpipe in its template emits - you call
markForCheck()yourself
// version A
addOrder(o: Order) {
this.orders.push(o);
}
// version B
addOrder(o: Order) {
this.orders = [...this.orders, o];
}
OnPush requires immutable data. Replace arrays and objects instead of mutating them. This is the single most common OnPush bug, and the reason people give up on it.
If you genuinely can't avoid mutation, inject ChangeDetectorRef and call
markForCheck() — but treat that as a last resort.
Covered in full at Q9. The performance summary:
| Without | Reassigning the array destroys and rebuilds every DOM node |
| With | Angular matches by id and touches only what changed |
| Biggest win | Long lists that refresh often — a live dashboard, a polled grid |
| Also fixes | Lost focus, lost scroll position, lost input state |
Track by a stable id. Tracking by index defeats the purpose the moment anything is inserted or reordered.
Covered at Q32. As a performance answer:
| What it fixes | Time to first paint — the browser downloads less before it can show anything |
| How you measure it | The chunk sizes printed by ng build |
| What to split first | Admin and reporting areas — most users never open them |
| The refinement | PreloadAllModules — small first load, then fetch the rest quietly |
Avoiding unnecessary API calls
| Problem | Fix |
|---|---|
| A request on every keystroke | debounceTime(300) |
| The same term searched twice | distinctUntilChanged() |
| Old responses overwriting new ones | switchMap — cancels the previous |
| Two components asking for the same list | shareReplay(1) |
| Reference data fetched on every screen | Cache it in a service |
| async used twice on one observable | shareReplay(1), or subscribe once |
// typeahead: ~1 request per pause instead of one per letter
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(t => this.api.search(t))
)
// reference data fetched once, shared by everyone
countries$ = this.http.get<Country[]>('/api/countries').pipe(
shareReplay(1)
);
Mostly RxJS. On a search box, debounceTime and distinctUntilChanged cut it from one request
per keystroke to one per pause, and switchMap cancels the superseded ones so a slow early
response can't overwrite the right results. For reference data I cache with shareReplay so
several components share one call rather than each firing their own.
| Cause | Fix |
|---|---|
{{ getTotal() }} in a template | It runs on every change detection cycle. Compute it once into a field. |
| Impure pipes | Same problem — they run constantly. Keep pipes pure. |
| Rendering 5,000 rows | Virtual scroll (cdk-virtual-scroll-viewport) or paging |
| A huge initial bundle | Lazy loading; check the build output |
| Deep component trees, default CD | OnPush on the leaves |
| Unnecessary subscriptions | async pipe; unsubscribe properly |
Angular 16+ signals are the newer answer: a signal tells Angular exactly what changed, so it can update just that binding rather than re-checking a whole tree. Worth knowing the name and the reason — you don't need to have used them.