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:
Signals : When signal values change
Events : DOM event handlers
Async Pipe : Observable emissions in templates
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
});
}
}
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