Skip to main content

Overview

Zoneless change detection eliminates the need for Zone.js, Angular’s traditional mechanism for automatic change detection. This results in smaller bundle sizes, better performance, and more predictable behavior.

Why Go Zoneless?

Smaller Bundle Size

Removing Zone.js can reduce your bundle size by ~30-50KB gzipped.

Better Performance

More efficient change detection with reduced overhead from zone tracking.

Improved Debugging

Easier debugging without Zone.js patching async operations.

Modern Primitives

Works seamlessly with Signals and reactive patterns.

Enabling Zoneless Mode

Use provideZonelessChangeDetection() to enable zoneless mode in your application.

Standalone Application

import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection()
  ]
}).catch(err => console.error(err));

NgModule Application

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule],
  providers: [
    provideZonelessChangeDetection()
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}
Once you enable zoneless mode, Zone.js is completely removed from your application. Make sure your code is compatible before making the switch.

How It Works

Instead of automatically detecting changes after every async operation, zoneless mode relies on explicit change detection triggers:
  1. Signals: When signal values change
  2. Events: DOM event handlers
  3. Async Pipe: Observable emissions in templates
  4. Manual: Explicit ChangeDetectorRef calls
import { Component, signal, ChangeDetectorRef } from '@angular/core';

@Component({
  selector: 'app-zoneless-demo',
  template: `
    <div>
      <!-- ✅ Automatically updates (signal) -->
      <p>Count: {{ count() }}</p>
      <button (click)="increment()">Increment</button>

      <!-- ✅ Automatically updates (async pipe) -->
      <p>Time: {{ time$ | async }}</p>

      <!-- ❌ Won't update automatically -->
      <p>Manual: {{ manualValue }}</p>
      <button (click)="updateManual()">Update Manual</button>
    </div>
  `
})
export class ZonelessDemoComponent {
  // Signal-based value: updates automatically
  count = signal(0);
  
  // Observable: updates through async pipe
  time$ = interval(1000).pipe(
    map(() => new Date().toLocaleTimeString())
  );

  // Regular property: needs manual change detection
  manualValue = 0;

  constructor(private cdr: ChangeDetectorRef) {}

  increment(): void {
    // Signal update triggers change detection
    this.count.update(n => n + 1);
  }

  updateManual(): void {
    this.manualValue++;
    // Must manually trigger change detection
    this.cdr.markForCheck();
  }
}

Migration Guide

Step 1: Use Signals for State

Replace component properties with signals where possible.
// Before (Zone.js)
import { Component } from '@angular/core';

@Component({
  selector: 'app-user',
  template: `<p>{{ userName }}</p>`
})
export class UserComponent {
  userName = 'John';

  updateName(name: string): void {
    this.userName = name; // Automatically detected by Zone.js
  }
}

// After (Zoneless)
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-user',
  template: `<p>{{ userName() }}</p>`
})
export class UserComponent {
  userName = signal('John');

  updateName(name: string): void {
    this.userName.set(name); // Triggers change detection
  }
}

Step 2: Use Async Pipe for Observables

Always use the async pipe in templates for observables.
// Before (manual subscription)
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { DataService } from './data.service';

@Component({
  selector: 'app-data',
  template: `<p>{{ data }}</p>`
})
export class DataComponent implements OnInit, OnDestroy {
  data: string = '';
  private subscription?: Subscription;

  constructor(private dataService: DataService) {}

  ngOnInit(): void {
    this.subscription = this.dataService.getData().subscribe(
      value => this.data = value
    );
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}

// After (async pipe)
import { Component } from '@angular/core';
import { DataService } from './data.service';

@Component({
  selector: 'app-data',
  template: `<p>{{ data$ | async }}</p>`
})
export class DataComponent {
  data$ = this.dataService.getData();

  constructor(private dataService: DataService) {}
}

Step 3: Handle Async Operations

For callbacks and third-party integrations, manually trigger change detection.
import { Component, signal, ChangeDetectorRef, OnInit } from '@angular/core';

@Component({
  selector: 'app-websocket',
  template: `
    <div>
      <p>Status: {{ status() }}</p>
      <p>Message: {{ lastMessage() }}</p>
    </div>
  `
})
export class WebSocketComponent implements OnInit {
  status = signal<'connected' | 'disconnected'>('disconnected');
  lastMessage = signal('');
  private ws?: WebSocket;

  constructor(private cdr: ChangeDetectorRef) {}

  ngOnInit(): void {
    this.ws = new WebSocket('wss://example.com/socket');

    this.ws.onopen = () => {
      // Using signals - no manual CD needed
      this.status.set('connected');
    };

    this.ws.onmessage = (event) => {
      // Using signals - no manual CD needed
      this.lastMessage.set(event.data);
    };
  }
}

Step 4: Update Event Handlers

Event handlers in templates automatically trigger change detection.
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-form',
  template: `
    <div>
      <input 
        [value]="searchTerm()"
        (input)="onSearchChange($event)"
      >
      <button (click)="search()">Search</button>
      <p>Results: {{ resultsCount() }}</p>
    </div>
  `
})
export class FormComponent {
  searchTerm = signal('');
  resultsCount = signal(0);

  onSearchChange(event: Event): void {
    // Event handlers trigger change detection
    const value = (event.target as HTMLInputElement).value;
    this.searchTerm.set(value);
  }

  async search(): Promise<void> {
    const results = await this.performSearch(this.searchTerm());
    // Signal update triggers change detection
    this.resultsCount.set(results.length);
  }

  private async performSearch(term: string): Promise<any[]> {
    // Simulated async search
    return [];
  }
}

Advanced Patterns

Custom Change Detection Strategy

import { 
  Injectable, 
  ChangeDetectorRef, 
  ApplicationRef 
} from '@angular/core';

@Injectable({ providedIn: 'root' })
export class CustomScheduler {
  private pending = new Set<ChangeDetectorRef>();
  private scheduled = false;

  constructor(private appRef: ApplicationRef) {}

  schedule(cdr: ChangeDetectorRef): void {
    this.pending.add(cdr);
    
    if (!this.scheduled) {
      this.scheduled = true;
      queueMicrotask(() => this.flush());
    }
  }

  private flush(): void {
    this.scheduled = false;
    const cdrs = Array.from(this.pending);
    this.pending.clear();
    
    cdrs.forEach(cdr => cdr.markForCheck());
    this.appRef.tick();
  }
}

Zoneless Service with Signals

import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

interface User {
  id: number;
  name: string;
  email: string;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private users = signal<User[]>([]);
  private loading = signal(false);
  private error = signal<string | null>(null);

  // Computed selectors
  readonly allUsers = this.users.asReadonly();
  readonly isLoading = this.loading.asReadonly();
  readonly errorMessage = this.error.asReadonly();
  
  readonly userCount = computed(() => this.users().length);
  readonly hasUsers = computed(() => this.users().length > 0);

  constructor(private http: HttpClient) {}

  async loadUsers(): Promise<void> {
    this.loading.set(true);
    this.error.set(null);

    try {
      const users = await this.http
        .get<User[]>('/api/users')
        .toPromise();
      
      this.users.set(users || []);
    } catch (err) {
      this.error.set('Failed to load users');
      console.error(err);
    } finally {
      this.loading.set(false);
    }
  }

  addUser(user: User): void {
    this.users.update(users => [...users, user]);
  }

  removeUser(id: number): void {
    this.users.update(users => users.filter(u => u.id !== id));
  }
}

Testing Zoneless Applications

import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { CounterComponent } from './counter.component';

describe('CounterComponent (Zoneless)', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CounterComponent],
      providers: [
        provideZonelessChangeDetection()
      ]
    }).compileComponents();
  });

  it('should increment counter', async () => {
    const fixture = TestBed.createComponent(CounterComponent);
    const component = fixture.componentInstance;
    
    // Initial value
    expect(component.count()).toBe(0);

    // Increment
    component.increment();
    
    // Wait for stability
    await fixture.whenStable();
    
    // Verify update
    expect(component.count()).toBe(1);
    expect(fixture.nativeElement.textContent).toContain('Count: 1');
  });
});
In zoneless mode, fixture.detectChanges() is deprecated. Use await fixture.whenStable() instead to wait for async updates.

Common Pitfalls

Avoid modifying component state in third-party callbacks without using signals or manual change detection.
// ❌ Bad: Direct property mutation
export class BadComponent {
  data = '';

  ngOnInit(): void {
    someThirdPartyLib.onData((value) => {
      this.data = value; // Won't trigger change detection!
    });
  }
}

// ✅ Good: Use signals
export class GoodComponent {
  data = signal('');

  ngOnInit(): void {
    someThirdPartyLib.onData((value) => {
      this.data.set(value); // Triggers change detection
    });
  }
}

Performance Optimization

Zoneless mode provides several performance benefits:
  • Reduced overhead: No Zone.js patches on async operations
  • Predictable timing: Change detection only runs when needed
  • Better tree-shaking: Smaller final bundle without Zone.js
  • Efficient updates: Fine-grained reactivity with signals

Best Practices

Prefer Signals

Use signals for all reactive state to get automatic change detection.

Use Async Pipe

Always use async pipe for observables in templates.

Avoid SetTimeout

Replace setTimeout/setInterval with observables or signals.

Test Thoroughly

Test your application extensively when migrating to zoneless.

Additional Resources

Build docs developers (and LLMs) love