Angular Change Detection and Signal Performance
Angular Change Detection and Signal Performance
The Symptom
The e-commerce inventory dashboard built with Angular updates stock levels every 5 seconds via a WebSocket connection. Each WebSocket message carries stock data for one product. With 200 products in the dashboard, the WebSocket delivers ~40 messages per second (200 products / 5-second rotation).
Each WebSocket message triggers Zone.js, which triggers ApplicationRef.tick(), which runs change detection across the entire component tree. The dashboard has 412 components. Change detection visits all 412 components, 40 times per second. Total change detection work: 16,480 component checks per second.
The main thread is saturated. Input responsiveness degrades. Clicking a sort button takes 200ms to respond because the click event queues behind change detection cycles.
The Cause
Zone.js patches every async API: setTimeout, setInterval, Promise.then, addEventListener, XMLHttpRequest, WebSocket.onmessage. Each patched callback triggers Angular change detection. For the WebSocket use case, every stock update message triggers a full tree check.
Default change detection does not distinguish between “this component’s data changed” and “some async event fired somewhere in the application.” Every async event checks every component.
The Baseline
Inventory dashboard with default change detection:
| Metric | Value |
|---|---|
| WebSocket messages/second | 40 |
| Change detection cycles/second | 40 |
| Components checked per cycle | 412 |
| Total component checks/second | 16,480 |
| Change detection time per cycle | 8ms |
| Main thread time in CD per second | 320ms (32% of budget) |
| INP (click sort button) | 200ms |
The Fix
Step 1: OnPush on Leaf Components
Convert product row components to OnPush. These components receive product data via @Input() and only need to re-check when that input changes:
// SLOW: Default change detection - checked 40x/second even when data unchanged
@Component({
selector: "app-inventory-row",
template: `
<tr>
<td>{{ product.name }}</td>
<td>{{ product.sku }}</td>
<td [class.text-red-600]="product.quantity < product.reorderPoint">
{{ product.quantity }}
</td>
<td>{{ product.warehouse }}</td>
<td>{{ product.lastUpdated | date: "HH:mm:ss" }}</td>
</tr>
`,
})
export class InventoryRowComponent {
@Input() product!: InventoryProduct;
}
// FAST: OnPush - checked only when product input reference changes
@Component({
selector: "app-inventory-row",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<tr>
<td>{{ product.name }}</td>
<td>{{ product.sku }}</td>
<td [class.text-red-600]="product.quantity < product.reorderPoint">
{{ product.quantity }}
</td>
<td>{{ product.warehouse }}</td>
<td>{{ product.lastUpdated | date: "HH:mm:ss" }}</td>
</tr>
`,
})
export class InventoryRowComponent {
@Input() product!: InventoryProduct;
}
The template is identical. Only the changeDetection metadata changes. But this requires the parent to pass a new object reference when the product data changes, not mutate the existing object:
// Parent must use immutable updates
@Component({
selector: "app-inventory-table",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<table>
<tbody>
@for (product of products; track product.sku) {
<app-inventory-row [product]="product" />
}
</tbody>
</table>
`,
})
export class InventoryTableComponent {
@Input() products: InventoryProduct[] = [];
}
The track product.sku expression tells Angular’s @for loop to identify items by SKU. When the products array updates, Angular compares SKUs to determine which items changed, which were added, and which were removed. Only changed items trigger re-render of their InventoryRowComponent.
Step 2: Signal-Based State Management
Replace the service’s BehaviorSubject pattern with signals:
// SLOW: BehaviorSubject triggers Zone.js on every emission
@Injectable({ providedIn: "root" })
export class InventoryService {
private productsSubject = new BehaviorSubject<InventoryProduct[]>([]);
products$ = this.productsSubject.asObservable();
constructor(private ws: WebSocketService) {
this.ws.messages$.subscribe((msg: StockUpdate) => {
const current = this.productsSubject.getValue();
const updated = current.map((p) =>
p.sku === msg.sku
? { ...p, quantity: msg.quantity, lastUpdated: msg.timestamp }
: p,
);
this.productsSubject.next(updated); // Triggers Zone.js
});
}
}
// FAST: Signal-based - updates are granular
@Injectable({ providedIn: "root" })
export class InventoryService {
private readonly _products = signal<InventoryProduct[]>([]);
readonly products = this._products.asReadonly();
constructor() {
const ws = inject(WebSocketService);
ws.messages$.subscribe((msg: StockUpdate) => {
this._products.update((current) =>
current.map((p) =>
p.sku === msg.sku
? { ...p, quantity: msg.quantity, lastUpdated: msg.timestamp }
: p,
),
);
});
}
}
The dashboard component reads the signal:
@Component({
selector: "app-inventory-dashboard",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="mb-4 flex items-center justify-between">
<h1>Inventory Dashboard</h1>
<span class="text-sm text-gray-500">
{{ updatedCount() }} products updated in last 60s
</span>
</div>
<app-inventory-table [products]="products()" />
<div class="mt-4">
<app-low-stock-alerts [alerts]="lowStockAlerts()" />
</div>
`,
imports: [InventoryTableComponent, LowStockAlertsComponent],
})
export class InventoryDashboardComponent {
private readonly inventoryService = inject(InventoryService);
products = this.inventoryService.products;
lowStockAlerts = computed(() =>
this.products().filter((p) => p.quantity < p.reorderPoint),
);
updatedCount = computed(
() =>
this.products().filter(
(p) => Date.now() - p.lastUpdated.getTime() < 60_000,
).length,
);
}
The lowStockAlerts computed signal recalculates only when products changes. If a WebSocket message updates a product that is not below reorder point, lowStockAlerts returns the same array reference. The LowStockAlertsComponent (OnPush) skips change detection because its input reference did not change.
Step 3: Zoneless Angular
For applications fully committed to signals, Zone.js can be removed entirely. This eliminates the automatic change detection triggered by every async event:
// main.ts: Bootstrap without Zone.js
import { bootstrapApplication } from "@angular/platform-browser";
import { provideExperimentalZonelessChangeDetection } from "@angular/core";
import { AppComponent } from "./app.component";
bootstrapApplication(AppComponent, {
providers: [provideExperimentalZonelessChangeDetection()],
});
Without Zone.js:
- WebSocket messages do not trigger change detection
setTimeoutandsetIntervaldo not trigger change detection- Only signal writes and
markForCheck()calls trigger targeted change detection
The WebSocket service must explicitly notify Angular when signals update. With signal.update(), Angular’s signal-based change detection handles this automatically. No manual ChangeDetectorRef.markForCheck() is needed.
Bundle size impact of removing Zone.js: zone.js is 13.6KB gzipped. Removing it reduces the initial JavaScript payload, improving the bundle size gate from Chapter 2.
Profiling Change Detection
// Enable Angular's built-in profiler
import { enableProdMode } from "@angular/core";
// In development, Angular DevTools shows:
// - Number of change detection cycles
// - Components checked per cycle
// - Time per cycle
// - Components that triggered change detection
// Custom profiling for production monitoring:
@Injectable({ providedIn: "root" })
export class ChangeDetectionProfiler {
private cycleCount = 0;
private totalTime = 0;
onCycleStart(): number {
this.cycleCount++;
return performance.now();
}
onCycleEnd(startTime: number): void {
const duration = performance.now() - startTime;
this.totalTime += duration;
if (this.cycleCount % 100 === 0) {
const avgTime = this.totalTime / this.cycleCount;
console.log(
`CD stats: ${this.cycleCount} cycles, avg ${avgTime.toFixed(2)}ms`,
);
}
}
}
The Angular DevTools “Profiler” tab in Chrome DevTools visualizes change detection. Enable “Highlight updates” to see which components re-render on each cycle. Components that flash on every WebSocket message but show no visible change are candidates for OnPush.
The Proof
| Metric | Default CD | OnPush | Signals + Zoneless |
|---|---|---|---|
| CD cycles/second | 40 | 40 | 40 (targeted) |
| Components checked/cycle | 412 | 12 | 2 |
| Total checks/second | 16,480 | 480 | 80 |
| CD time/cycle | 8ms | 0.6ms | 0.1ms |
| Main thread in CD/second | 320ms | 24ms | 4ms |
| INP (sort button) | 200ms | 24ms | 8ms |
| Bundle size delta | baseline | 0 | -13.6KB (no zone.js) |
The INP for the sort button drops from 200ms to 8ms. The main thread is no longer saturated by change detection, leaving 96% of the frame budget for user interactions.
The CI performance gate catches INP regressions. If a developer adds a component without OnPush, the Lighthouse TBT increases and the gate warns. The bundle size gate catches Zone.js reintroduction (if a dependency imports Zone.js, the bundle grows by 13.6KB).
The Trade-off
OnPush requires immutable data patterns throughout the application. Every state update must produce a new object reference. Mutating an existing object and expecting Angular to detect the change is a common bug:
// Bug: mutation does not trigger OnPush change detection
this.products[0].quantity = 50; // Component does not update
// Correct: immutable update creates new reference
this.products = this.products.map((p, i) =>
i === 0 ? { ...p, quantity: 50 } : p,
);
Zoneless Angular is experimental. The API may change. Third-party libraries that depend on Zone.js for change detection (some Material components, legacy directive libraries) may not work correctly. The migration path: start with OnPush on all components, then evaluate zoneless after confirming all dependencies support signal-based detection.
Signal-based state management requires rewriting services that use RxJS BehaviorSubject or ngrx Store patterns. For the inventory dashboard with 3 services and 8 components, this is a manageable migration. For a 200-component enterprise application, the migration is phased: convert leaf components first, then work up the component tree. Each converted component reduces change detection work independently, providing incremental performance gains.