Angular Change Detection and Performance: Zone.js, OnPush, and Going Zoneless
Change detection is the question every UI framework must answer: state changed โ how does the screen find out? Angular has answered it three different ways across its life (zone-based check-everything, OnPush pruning, and now signal-driven precision), and real codebases contain archaeology from all three eras. Understanding the system is what separates developers who fix performance problems from those who sprinkle memoization and hope.
This guide tells the story in order โ because each era explains the next โ then delivers the practical playbook: what actually makes Angular apps slow and what actually fixes them.
Era One: Zone.js and the Check-Everything Model
Classic Angularโs insight: DOM updates can only be needed after something happened โ a click, a timer, an HTTP response, a resolved promise. So Angular shipped zone.js, a library that monkey-patches every async API in the browser (setTimeout, listeners, promises) to notify Angular: โan event just finished โ something may have changed.โ
And then, the blunt part: Angular would re-check every binding in every component, top of the tree to the bottom. {{ user.name }}, [disabled]="count === 0" โ each expression re-evaluated, compared to its previous value, DOM patched where different.
Judge it fairly: this design made Angular magically ergonomic โ mutate any property anywhere (this.user.name = 'X') and the UI just updated, no ceremony, no setState. The cost was proportionality: every keystroke triggered a full-tree check, and a 5,000-binding app did 5,000 comparisons per event. Fine at small scale, and the source of the classic large-app symptoms: sluggish typing in forms, jank during scroll-driven events, and profiler flame charts dominated by change-detection passes.
This era also birthed the infamous ExpressionChangedAfterItHasBeenCheckedError โ dev-mode Angular runs the check twice to catch bindings that changed during checking (state mutated in the wrong lifecycle moment); the error means โyour rendering has a feedback loop,โ and its cures (move the mutation, defer it) make sense only once you know the double-check exists.
Era Two: OnPush โ Pruning the Tree
ChangeDetectionStrategy.OnPush was the optimization contract: this component only needs checking if its inputs changed (by reference), an event fired inside it, or an async pipe emitted. Otherwise, skip it โ and its entire subtree:
@Component({ selector: 'app-order-row', changeDetection: ChangeDetectionStrategy.OnPush, ...})A list of 500 OnPush rows re-checks only the row whose button was clicked, not all 500. The catch โ and the source of a thousand โmy UI stopped updatingโ bugs โ is the by reference clause:
this.order.status = 'shipped'; // OnPush child bound to [order]: sees NOTHINGthis.order = { ...this.order, status: 'shipped' }; // new reference: updates โOnPush is thus inseparable from immutable update discipline โ the same reference-equality contract as pure pipes and @for tracking. Teams that adopted โOnPush everywhere + immutable dataโ got fast Angular; teams that half-adopted got whack-a-mole staleness patched with ChangeDetectorRef.markForCheck() calls (the manual โplease check meโ escape hatch youโll recognize all over legacy code, along with detectChanges() โ read them as symptoms of the eraโs contract being fought rather than followed).
Era Three: Signals and Zoneless โ Precision
Signals inverted the question. Instead of โan event happened, what might have changed?โ, the dependency graph answers โthis signal changed โ exactly these views read it.โ No zone patching, no tree walking, no reference-comparison contract to violate: reads register dependencies, writes notify dependents, rendering updates precisely the affected views.
Which makes zone.js removable โ zoneless Angular:
provideZonelessChangeDetection()What changes in practice: change detection triggers only from signal writes, template events, and framework-known async (resources, async pipe) โ not from arbitrary setTimeout mutations. Code that relied on zone magic (setTimeout(() => this.someField = x) with a plain field) stops updating the UI โ which is precisely the migration checklist: state that drives templates becomes signals, and the ergonomics you already learned in this series are the zoneless-safe ones. The payoffs beyond speed: zone.jsโs bundle weight and stack-trace pollution gone, fewer main-thread interceptions, and โ since OnPush semantics fall out naturally when everything is signal-driven โ the entire era-two contract becomes implicit.
The migration reality for working developers: new apps start zoneless-friendly by default (signals everywhere); existing apps move incrementally โ OnPush + signals first (works beautifully with zones), zoneless flip when the codebase stops relying on zone triggers. All three eras coexist in the industry right now; youโll debug all three.
The Performance Playbook
With the theory placed, the practical hierarchy โ ordered by how often each is actually the problem:
1. Rendering too much, too often. The dominant cause. Fixes in order of leverage: track done right on every @for (wrong tracking = full list rebuilds โ the single most common Angular perf bug); OnPush/signals so unrelated updates donโt touch heavy subtrees; @defer for below-fold weight; pagination or virtual scrolling (CDK cdk-virtual-scroll-viewport) when lists exceed a few hundred rows โ the DOM should hold whatโs visible, not the dataset.
2. Expensive work inside bindings. Every checked binding runs per pass in zone-era code โ a sort inside a template method can run thousands of times. Fix: computeds and pure pipes so work runs on change, not on check. Symptom to hunt: any nontrivial function called from a template.
3. Update storms. High-frequency sources โ scroll, resize, keystrokes, WebSocket ticks โ each triggering detection. Fix: debounce/throttle at the source, batch socket updates before writing signals, and keep heavy computation off the main thread entirely (workers) when itโs genuinely heavy.
4. Bundle and startup cost โ a different axis (time-to-interactive rather than runtime smoothness), owned by lazy loading and build tooling, next guide.
And the meta-rule that governs all four: profile before optimizing. Angular DevToolsโ profiler shows change-detection cycles, which components consumed them, and how long โ one recording usually points at the guilty list or binding. Optimizing without it is how codebases accrue memoization scar tissue in the wrong places, the same discipline as JVM tuning: measure, then cut.
A Profiling Session, Narrated
Theory lands when youโve walked one real investigation. The setup: users report the orders page โlags when typing in the filter box.โ The session:
Step 1 โ reproduce and record. Angular DevTools โ Profiler โ record while typing five characters. The timeline shows five change-detection cycles, each ~180ms โ well past the 50ms long-task threshold, so typing drops frames. Expanding one cycle: OrderRow ร 800 accounts for nearly all of it.
Step 2 โ form the hypothesis. 800 rows re-checked per keystroke means the list isnโt isolated from the filterโs updates. Two suspects from the playbook: no OnPush/signal isolation (item 1), or per-row template work (item 2).
Step 3 โ inspect the code. The row template contains {{ formatAddress(order) }} โ a method call doing string assembly, running 800ร per cycle (item 2 confirmed). And the filtering happens in the parentโs template binding โ @for (o of filterOrders(orders, query)) โ re-filtering and re-creating the array every check, which with track o (object identity) also rebuilds rows whose objects were recreated (item 1โs cousin: identity churn).
Step 4 โ apply the matched fixes. filtered = computed(() => filterOrders(this.orders(), this.query())) โ the filter now runs on change, not per check, and returns stable objects between query changes; formatAddress becomes a computed on the row (or a pipe); track o.id replaces identity tracking.
Step 5 โ measure again. Re-record: cycles at ~8ms, dominated by the rows whose visibility actually changed. Ship it, note the pattern in review guidelines.
The transferable part isnโt the specific fixes โ itโs the sequence: measure, attribute (which components, which bindings), match to the playbook, re-measure. Every performance war story that ends badly skipped step 1 or step 5.
Frequently Asked Questions
Should I set OnPush on every component today? In signal-forward code itโs increasingly moot (signal-driven views behave OnPush-like anyway, and new-project schematics lean that way) โ but on zone-era codebases, yes: OnPush-everywhere with immutable data remains the single highest-leverage setting, and itโs the compatibility bridge toward zoneless.
Does runOutsideAngular still matter? In zone-era apps, yes โ itโs how you run high-frequency callbacks (animation loops, chart libraries) without triggering detection per frame, re-entering deliberately when state must update. Zoneless makes the pattern obsolete โ thereโs no zone to escape โ which is itself a good argument for the migration.
Why does my app update correctly but feel janky anyway? Change detection being correct says nothing about cost โ profile: itโs usually item 1 (list rebuilds from bad tracking) or item 2 (template-method work), occasionally layout thrash below Angular entirely.
Is ExpressionChanged... an error I fix or suppress? Fix โ itโs a real feedback loop (state written during render). Typical causes: mutating state in ngAfterViewInit-era hooks or child components writing parent state during initialization. Restructure when the write happens (effects, afterNextRender, or event-driven); suppressing with detectChanges() buries the loop.
How do signals interact with the profiler story? Beautifully โ precise invalidation means profiler traces show small, attributable work per update instead of monolithic tree passes; regressions become โthis component re-rendered 400 timesโ findings rather than diffuse slowness.
The Interview Version
Change detection is the definitive senior-Angular interview topic. The compressed narrative that demonstrates depth: โClassic Angular used zone.js to patch async APIs and re-check the whole component tree after any event โ ergonomic, but O(app) work per keystroke. OnPush pruned subtrees by contract: check only on input reference changes, internal events, or async emissions โ which is why OnPush and immutable updates are inseparable. Signals replaced guessing with knowledge: reads register dependencies, writes notify exactly the affected views, making zone.js removable entirely โ zoneless Angular. Practically, most perf problems are still list tracking, template-method work, and update storms โ profile first.โ Follow-ups to expect: the ExpressionChanged error (writes during render, caught by dev-modeโs double check), markForCheck vs detectChanges (schedule-for-next-pass vs run-now โ and why both are era artifacts), and โwhy did my OnPush component not update?โ (mutation kept the reference โ the answer is a spread). Candidates who narrate the eras and their whys stand apart from those who recite โOnPush makes it faster.โ
One boundary clarification to carry forward: change detection governs when Angular updates the DOM, not how fast the browser renders it โ a perfectly optimized detection cycle can still jank on layout thrash, unbatched DOM reads, or paint-heavy CSS, which live below the framework. When the Angular profiler shows small cheap cycles but frames still drop, switch instruments: the browser Performance panel owns that layer.
Self-Check
- Zone-era app: typing in a header search box visibly stutters a heavy dashboard below. Explain the mechanism and name two era-appropriate fixes.
- An OnPush child bound to
[user]shows a stale name afterthis.user.name = xin the parent. Mechanism and the two fixes (one data-side, one escape-hatch)? - Why does wrong
trackin@forcause performance problems even when the UI looks correct? - A zoneless migration breaks a widget that updates a plain field in a
setTimeout. Why did it work before, why not now, and the fix? - Order these by typical real-world impact: OnPush adoption, fixing list tracking, memoizing one template function, virtual scrolling a 5,000-row table.
(Answers: every keystroke triggers full-tree checking including the dashboardโs bindings โ OnPush the dashboard subtree, and/or debounce the input at source. Mutation kept the reference; OnPush compares references โ replace with a spread-updated object, or markForCheck() as the legacy hatch. Identity confusion forces DOM teardown/rebuild of rows whose data โchangedโ only by reference โ CPU and GC churn invisible as wrongness but visible as jank. Zone.js patched setTimeout and triggered detection after it; zoneless only reacts to signals/events โ make the field a signal. Fixing tracking and virtual scrolling typically dwarf the others on big lists; OnPush is broad but diffuse; single-function memoization is the smallest.)
Final guide: Lazy Loading and Deployment โ route-level code splitting, build budgets, SSR basics, and shipping Angular to production.