AngularZone.jsPerformanceChange DetectionSignals

Zoneless Angular: Why zone.js is Finally Optional in 2026

A
Angular Core Contributor
Featured Guide 35 min read

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.

Zone.js Monkey Patching Example
// 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.

Zoneless Component Example
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.

Signal Types in Action
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: \`
    
@if (emailError()) {

{{ emailError() }}

} @if (passwordError()) {

{{ passwordError() }}

}
\` }) 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: {{ cpuUsage() }}%
Memory: {{ 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:

-35KB
Bundle Size
Zone.js removed from production build
-40%
Change Detection Time
From 0.8ms to 0.48ms average
+60%
Faster TTI
Time to Interactive improved significantly

Lighthouse Score Comparison

With Zone.js
Performance 78
First Contentful Paint 1.8s
Time to Interactive 3.2s
Zoneless
Performance 92
First Contentful Paint 1.4s
Time to Interactive 2.0s

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.