The Magic Comes at a Price.
For a decade, zone.js was Angular's defining featureβand its biggest bottleneck.
It monkey-patched standard browser APIs (setTimeout, Promise, etc.) to know when to update the UI. But "magic" change detection meant checking the entire component tree for every single click.
What Zone.js Actually Does
Zone.js wraps every asynchronous operation in your application. When you call setTimeout, you're actually calling Zone's patched version. This allows Angular to know when async operations complete and trigger change detection.
The problem? It checks everything, every time. Even if only one component's state changed, Angular walks the entire component tree checking bindings. For a 1,000-component app, that's 1,000 checks per click.
// What you write:
setTimeout(() => console.log('Hello'), 1000);
// What Zone.js does internally:
const originalSetTimeout = window.setTimeout;
window.setTimeout = function(fn, delay) {
return originalSetTimeout(() => {
fn();
// π₯ Trigger Angular Change Detection for ENTIRE app
ApplicationRef.tick();
}, delay);
};
The Hidden Cost
Zone.js adds ~35KB (gzipped) to your bundle. But the real cost is runtime performance. Every async operation triggers a full change detection cycle, even if nothing changed.
02. Enter Zoneless & Signals
Zoneless Angular doesn't guess. It knows. By relying on Signals, Angular receives precise notifications about exactly which node in the DOM needs to update.
Zone.js (Global Refresh)
1. User clicks button.
2. Zone intercepts event.
3. Triggers ApplicationRef.tick().
4. Checks 1,000 components dirty status.
5. Updates 1 text node.
Zoneless (surgical)
1. User clicks button.
2. Signal updates.
3. Angular updates exactly that 1 text node.
4. Done.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: \`
Count: {{ count() }}
\`
})
export class CounterComponent {
// β
Signal-based state
count = signal(0);
increment() {
this.count.update(v => v + 1);
// No Zone.js needed! Signal notifies Angular directly
}
}
03. Signals Deep Dive
How Signals Work Internally
Signals are Angular's implementation of fine-grained reactivity. They maintain a dependency graph between computed values and their sources.
WritableSignal
The source of truth. Can be updated via set() or update(). Notifies all subscribers when changed.
Computed Signal
Derived value that automatically recomputes when dependencies change. Memoized for performance.
Effect
Side-effect that runs when signals it reads change. Used for logging, analytics, etc.
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-cart',
standalone: true,
template: \`
Items: {{ items().length }}
Total: ${{ total() }}
\`
})
export class CartComponent {
// 1οΈβ£ WritableSignal - Source of truth
items = signal([]);
// 2οΈβ£ Computed Signal - Auto-updates when items change
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
// 3οΈβ£ Effect - Runs when signals change
constructor() {
effect(() => {
console.log('Cart updated:', this.items().length);
// Could send analytics here
});
}
addItem() {
this.items.update(current => [
...current,
{ id: Date.now(), price: 10 }
]);
// β
Only 'total' recomputes, only affected DOM updates
}
}
Deep Dive: The Dependency Graph
When you read a signal inside a computed() or effect(), Angular automatically tracks it as a dependency. When the signal updates, only the dependent computations re-run.
firstName = signal('John');
lastName = signal('Doe');
fullName = computed(() => \`${this.firstName()} ${this.lastName()}\`);
// Dependency graph:
// firstName βββ
// βββ> fullName
// lastName ββββ
this.firstName.set('Jane'); // β
fullName recomputes
this.lastName.set('Smith'); // β
fullName recomputes again
04. Migration Strategy
Step 1: Enable Zoneless Mode
// app.config.ts
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection() // The Magic Switch
]
};
Step 2: Convert State to Signals
Replace class properties with signals. This is the most time-consuming step but yields the biggest performance gains.
β Before (Zone-based)
export class UserComponent {
user: User | null = null;
loading = false;
async loadUser(id: string) {
this.loading = true;
this.user = await userService.get(id);
this.loading = false;
// Zone.js triggers change detection
}
}
β After (Zoneless)
export class UserComponent {
user = signal(null);
loading = signal(false);
async loadUser(id: string) {
this.loading.set(true);
const data = await userService.get(id);
this.user.set(data);
this.loading.set(false);
// Signal automatically notifies Angular
}
}
Step 3: Handle Third-Party Libraries
Some libraries still rely on Zone.js. You have two options:
Option 1: Manual Change Detection
import { ChangeDetectorRef } from '@angular/core';
constructor(private cdr: ChangeDetectorRef) {}
someThirdPartyCallback() {
this.data = newValue;
this.cdr.markForCheck(); // Manually trigger update
}
Option 2: Wrap in Signal
data = signal(initialValue);
someThirdPartyCallback() {
this.data.set(newValue); // Signal handles change detection
}
Note: AsyncPipe still works! But you should prefer Signals for future-proof code.
05. Real-World Examples
Example 1: Form with Validation
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-signup-form',
standalone: true,
template: \`
\`
})
export class SignupFormComponent {
email = signal('');
password = signal('');
// Computed validation - only re-runs when email changes
emailError = computed(() => {
const value = this.email();
if (!value) return 'Email required';
if (!value.includes('@')) return 'Invalid email';
return null;
});
// Computed validation - only re-runs when password changes
passwordError = computed(() => {
const value = this.password();
if (!value) return 'Password required';
if (value.length < 8) return 'Min 8 characters';
return null;
});
// Form validity - recomputes when either error changes
isValid = computed(() =>
!this.emailError() && !this.passwordError()
);
handleSubmit() {
if (this.isValid()) {
console.log('Submitting:', {
email: this.email(),
password: this.password()
});
}
}
}
Example 2: Real-Time Data Dashboard
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-dashboard',
standalone: true,
template: \`
Server Metrics
CPU Usage:
80">
{{ cpuUsage() }}%
Memory:
90">
{{ memoryUsage() }}%
Status: {{ systemStatus() }}
\`
})
export class DashboardComponent {
cpuUsage = signal(45);
memoryUsage = signal(62);
// Computed status based on metrics
systemStatus = computed(() => {
const cpu = this.cpuUsage();
const mem = this.memoryUsage();
if (cpu > 80 || mem > 90) return 'π΄ Critical';
if (cpu > 60 || mem > 70) return 'π‘ Warning';
return 'π’ Healthy';
});
constructor() {
// Effect for alerts - runs when status changes
effect(() => {
const status = this.systemStatus();
if (status.includes('Critical')) {
this.sendAlert('System critical!');
}
});
// Simulate real-time updates
setInterval(() => {
this.cpuUsage.set(Math.random() * 100);
this.memoryUsage.set(Math.random() * 100);
// β
Only affected computations re-run
// β
Only changed DOM nodes update
}, 2000);
}
sendAlert(message: string) {
console.warn('ALERT:', message);
}
}
06. Performance Benchmarks
Real-world performance improvements from migrating a production app with 500+ components:
Lighthouse Score Comparison
07. The Senior Engineer's Perspective
Should you migrate today?
If you are starting a new project in 2026, absolutely start Zoneless. It forces you to learn Signals properly, which is the future of the framework.
For legacy apps with heavy dependency on OnPush hacks or libraries that assume Zone exists, proceed with caution. The performance gains are real (especially TTI and bundle size), but the refactor cost can be high.
Migration Strategy: Start with new features in Zoneless mode. Gradually convert existing components. Use feature flags to test in production with a small percentage of users first.