Hook — 06 : 04 a.m., Medellín ↔ Ciudad de Panamá code-review
The sun hadn’t cleared the Andes when Carla, our newest junior in Panamá, pinged me: “The live price ticker freezes every 30 seconds.” I opened her repo from a Colombian balcony, parrots screeching behind me, and watched dev-tools flame charts glow red on each WebSocket burst. The culprit? Every push triggered Angular’s default change-detection cycle across 200 components. One coffee later we flipped three critical trees toChangeDetectionStrategy.OnPush
, leveraged signals for pinpoint updates, and CPU time plummeted. That dawn refactor is today’s roadmap: you’ll learn how Angular detects changes, why it sometimes over-works, and where fine-grained strategies keep apps smooth—whether you’re coding from Dominican rooftops or Mexican coworking lofts.
Why Change Detection Still Trips Teams in 2025
Challenge | Real-world cost | Angular 18 toolkit |
---|---|---|
Large component trees repaint on every async event | Jank on mid-range Androids | OnPush , signals, and markDirty() |
Third-party callbacks outside zone.js | Silent UI desyncs | runInInjectionContext() + manual detectChanges() |
Server-rendered pages flash on hydration | Layout shift penalties | Incremental hydration hydrates only visible zones |
Mastering change detection means faster Core Web Vitals, fewer rage clicks, and happier teammates across time zones.
Background: Zones, Trees & Dirty Checking (Plain English)
- Zone.js patches async APIs (
setTimeout
,fetch
) and notifies Angular when work finishes. - Angular traverses the component tree top-down, comparing bindings (template expressions) from last run to now.
- If a value changed, Angular updates the DOM; otherwise it walks past.
By default this happens on every async tick. Great for correctness, brutal for performance at scale.
Strategy 1 — Default
Detection
Concept
Every event inside the zone triggers a full tree check. Good for tiny apps, dangerous for dashboards.
Example
tsCopyEdit@Component({
selector: 'app-root',
template: `<price-ticker></price-ticker><news-feed></news-feed>`,
})
export class AppComponent {}
One WebSocket-driven price-ticker
update causes news-feed
to evaluate too, even if nothing changed.
Pitfall
Expensive pipes (| currency
) recalc on every tick; memoize or switch strategy.
Strategy 2 — OnPush
Detection
Concept
Angular skips a component unless one of these happens:
- An
@Input()
reference changes - You call
markDirty()
/detectChanges()
manually - An event handler inside the component fires
Migration Steps
bashCopyEditng g component product-card --change-detection=OnPush
tsCopyEdit@Component({
selector: 'product-card',
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
export class ProductCardComponent {
@Input() product!: Product; // ref change triggers check
}
Performance win — the card tree rechecks only when its
product
reference changes, not on every price push elsewhere.
Common Bug & Fix
Mutating an input object: this.product.price++
won’t fire detection. Fix: replace reference this.product = {...this.product, price: newValue}
.
Strategy 3 — Fine-Grained Signals (Angular 18 preview)
Angular 18 stabilizes signals for reactive primitives without Zone overhead.
tsCopyEditimport { signal, computed, effect } from '@angular/core';
export class TickerService {
private price = signal(0);
readonly doubled = computed(() => this.price() * 2);
connect(socket: WebSocket) {
socket.onmessage = e => this.price.set(Number(e.data));
}
}
Why it rocks
- No change-detection cycles—UI reacts only where the signal is read.
- Works with
OnPush
seamlessly—template bindings auto-subscribe.
Template:
htmlCopyEdit<div>{{ service.doubled() | currency }}</div>
Hands-On Walkthrough: Migrate a Default List to OnPush
+ Signals
- Generate Component
bashCopyEditng g component price-row --change-detection=OnPush
- Inject Signal
tsCopyEdit@Component({...})
export class PriceRowComponent {
@Input({ required: true }) price!: Signal<number>;
}
- Template
htmlCopyEdit<tr><td>{{ price() | number:'1.2-2' }}</td></tr>
- Parent Loops
htmlCopyEdit<price-row *ngFor="let p of prices" [price]="p"></price-row>
Each Signal<number>
updates its own row only, leaving siblings idle.
Remote-Work Insight 🌴
During a Panama-to-Brazil bug bash we saw random stale data on a map widget fed by a 3rd-party library outside Zone. Wrapping the callback with
NgZone.run()
fixed it, but cost performance. Instead we injectedChangeDetectorRef
and calledmarkDirty()
only after throttling updates withrequestIdleCallback
. Map stayed fresh, CPU dropped 30 %.
Performance & Accessibility Checkpoints
- React DevTools-like Profiler (
ng profiler
) — confirmChangeDetection
cycles shrink after OnPush. - Chrome Performance — flame chart should show fewer “FunctionCall” stacks on async events.
- Lighthouse TBT — aim < 200 ms on Fast 3G.
- Screen Readers — manual
detectChanges()
must run before announcing live-region messages.
Call-Out Table: When to Use Each Tool
Scenario | Strategy |
---|---|
CRUD admin panel, small tree | Default |
Large list, unchanged siblings | OnPush |
Real-time charts (≤ 60 fps) | Signals + OnPush |
3rd-party lib outside Zone | markDirty()/detectChanges() |
SSR with incremental hydration | OnPush + hydration flags |
Common Pitfalls & Remedies
Symptom | Root cause | Fix |
---|---|---|
Template doesn’t refresh | Mutated object w/ OnPush | Replace reference |
ExpressionChangedAfterItHasBeenChecked | Async set within same tick | Wrap in setTimeout(0) or NgZone.onStable |
Memory leak with signals | Unsubscribed effects | Return cleanup function from effect() |
Essential CLI Commands
Command | Purpose |
---|---|
ng g c cmp --change-detection=OnPush | Scaffold OnPush component |
ng profiler timeChanges | Measure CD time |
ng add @angular/ssr | Test incremental hydration |
ng build --stats-json | Analyze bundle for zone.js size |
SVG Diagram Idea
Layers: Zone event ➜ ChangeDetection scheduler ➜ Component tree (Default) vs. Branch-only (OnPush) vs. Direct binding (Signal). Arrows show reduced checks.
Wrap-Up
Change detection is Angular’s heartbeat—understand the rhythm and you can tune performance like a salsa drummer in Cartagena. Use Default for simplicity, OnPush for scale, and signals for surgical reactivity. Combine them thoughtfully, profile often, and your apps will feel native from Brazilian fintech desks to Costa Rican surf shacks. Got questions or war stories? Drop them below—I’ll reply between airport layovers and late-night arepa sessions.