
Going Zoneless in Angular 20.2: The Complete Guide

D. Rout
February 20, 2026 9 min read
On this page
Angular has always had a reputation for being "magical" — you change a variable and the UI just updates. For years, the engine behind that magic was Zone.js, a library that silently monkey-patched browser APIs to tell Angular when anything async happened. With Angular 20.2, that magic has a cleaner, faster replacement: zoneless change detection, now promoted to stable and ready for production.
In this tutorial we'll walk through how Zone.js worked, what zoneless mode actually is, why it's a significant improvement, and how to migrate your app with real code examples.
How Zone.js and Change Detection Used to Work
To understand why zoneless matters, you need to understand the problem it solves.
Angular's job is to keep your component templates in sync with your TypeScript data. The mechanism that does this is change detection. The question Angular has always needed to answer is: when should I check for changes?
Enter Zone.js. It works by patching virtually every asynchronous browser API — setTimeout, setInterval, Promise, fetch, addEventListener, and many more. Every time one of those wrapped operations completes, Zone.js fires a notification: "Something happened — better check your app."
Angular then walks the entire component tree from the root down, checking every component's bindings for differences. If a binding changed, it updates the DOM.
// Zone-based Angular (legacy)
// Any of these would silently trigger a full tree check:
setTimeout(() => {
this.title = 'Updated!'; // Zone.js catches this
}, 1000);
fetch('/api/data').then(res => res.json()).then(data => {
this.items = data; // Zone.js catches this too
});
This approach is powerful and developer-friendly — you barely had to think about change detection. But it came with real costs.
The Problems with Zone.js
1. Unnecessary change detection cycles
Zone.js doesn't know what changed — it only knows that something async happened. So Angular checks everything, every time, even if the async operation had nothing to do with your UI. A background analytics ping? Full tree check. A setTimeout for a log message? Full tree check.
2. Bundle size overhead
Zone.js adds roughly 30kB raw (≈10kB gzipped) to your initial bundle. That's a dependency that must be eagerly loaded before your application even starts bootstrapping.
3. Debugging pain
Because Zone.js wraps every async call, call stacks become polluted with Zone-specific frames. A simple click handler could produce a 40-frame stack trace where half the frames belong to Zone.js internals — not your code.
4. Opacity
The "magic" also made the system opaque. It was genuinely hard to understand why change detection ran at a given moment, making performance profiling difficult.
What Is Zoneless Change Detection?
Zoneless mode removes Zone.js entirely from the equation. Instead of Zone.js broadcasting "something happened" and Angular checking everything just in case, Angular now waits for explicit notifications that something worth re-rendering has occurred.
When a Signal is updated, Angular calls markDirty() only for the components that depend on it — rather than walking the entire tree from the root.
The notifications that trigger change detection in zoneless mode are:
- A Signal value is updated (the primary pattern)
- **An `OnPush` component receives a new `@Input()` value**
markForCheck()is called explicitly- The
asyncpipe resolves a new value afterNextRender/afterEveryRendercallbacks
As of Angular v20.2, zoneless Angular is stable and ready for production use.
Setting Up Zoneless in Angular 20.2
Step 1 — Enable the provider
In a standalone app, add provideZonelessChangeDetection() to your bootstrap providers:
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(), // ← this is all you need
],
});
For NgModule-based apps:
// app.module.ts
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 {}
Step 2 — Remove Zone.js from the build
Zoneless applications should remove Zone.js entirely from the build to reduce bundle size. Remove zone.js and zone.js/testing from the polyfills option in angular.json for both the build and test targets. Projects using an explicit polyfills.ts file should remove import 'zone.js' and import 'zone.js/testing' from that file.
// angular.json — before
"polyfills": ["zone.js"]
// angular.json — after
"polyfills": []
Then uninstall the package entirely:
npm uninstall zone.js
Writing Zoneless-Compatible Components
The key shift is moving to Signals for reactive state. Signals are Angular's built-in reactive primitives that let the framework know exactly which components depend on which data.
Before: Zone-based component
// counter.component.ts (Zone-based)
import { Component } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count }}</p>
<button (click)="increment()">+1</button>
`,
})
export class CounterComponent {
count = 0;
increment() {
this.count++; // Zone.js detects the event and triggers CD
}
}
After: Zoneless component with Signals
// counter.component.ts (Zoneless + Signals)
import { Component, signal } from '@angular/core';
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+1</button>
`,
})
export class CounterComponent {
count = signal(0); // reactive signal
increment() {
this.count.update(c => c + 1); // notifies Angular automatically
}
}
Angular now knows this component reads count, so when count changes, only this component is marked dirty and re-checked.
Computed Signals and Effects
Signals compose naturally using computed() and effect():
import { Component, signal, computed, effect } from '@angular/core';
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-cart',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Items: {{ itemCount() }}</p>
<p>Total: ${{ total() }}</p>
<button (click)="addItem(9.99)">Add Item ($9.99)</button>
`,
})
export class CartComponent {
prices = signal<number[]>([]);
itemCount = computed(() => this.prices().length);
total = computed(() =>
this.prices().reduce((sum, price) => sum + price, 0).toFixed(2)
);
constructor() {
// effect() re-runs whenever its signals change
effect(() => {
console.log(`Cart updated: ${this.itemCount()} items`);
});
}
addItem(price: number) {
this.prices.update(list => [...list, price]);
}
}
computed() values are lazily evaluated and memoized — they only recalculate when a dependency signal changes.
Handling Async Operations (HTTP, Timers)
This is the most important migration point. In a zoneless app, setTimeout and fetch no longer automatically trigger change detection.
HTTP with toSignal()
The cleanest pattern for HTTP data is pairing HttpClient observables with toSignal():
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { ChangeDetectionStrategy } from '@angular/core';
interface User {
id: number;
name: string;
}
@Component({
selector: 'app-users',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (users()) {
<ul>
@for (user of users(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
} @else {
<p>Loading...</p>
}
`,
})
export class UsersComponent {
private http = inject(HttpClient);
// Observable → Signal bridge: change detection is automatic
users = toSignal(
this.http.get<User[]>('https://jsonplaceholder.typicode.com/users')
);
}
setTimeout with manual markForCheck()
If you must use setTimeout (for example with third-party code), call markForCheck() explicitly:
import { Component, signal, ChangeDetectorRef, inject, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-timer',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>Message: {{ message() }}</p>`,
})
export class TimerComponent {
message = signal('Waiting...');
private cdr = inject(ChangeDetectorRef);
ngOnInit() {
setTimeout(() => {
this.message.set('Done!');
// Signal update handles markForCheck automatically,
// but for non-signal state you'd call:
// this.cdr.markForCheck();
}, 2000);
}
}
Tip: Prefer updating a Signal over calling
markForCheck()directly. Signal updates are self-announcing.
Testing Zoneless Components
To force zoneless mode in unit tests (even when zone.js is loaded), add provideZonelessChangeDetection() to TestBed.configureTestingModule() providers.
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
});
it('should increment the count signal', async () => {
const fixture = TestBed.createComponent(CounterComponent);
await fixture.whenStable(); // use this instead of fixture.detectChanges()
const button = fixture.nativeElement.querySelector('button');
button.click();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('p').textContent).toContain('1');
});
});
Avoid fixture.detectChanges() in zoneless tests where possible — it forces change detection and may mask issues. await fixture.whenStable() is the preferred pattern.
Migration Checklist
Here's a quick summary of what to do when migrating an existing app:
| Step | Action |
|---|---|
| 1 | Add provideZonelessChangeDetection() to bootstrap providers |
| 2 | Remove zone.js from angular.json polyfills |
| 3 | Uninstall the zone.js package |
| 4 | Replace mutable component state with signal() |
| 5 | Replace Observable subscriptions in templates with toSignal() or async pipe |
| 6 | Remove any usage of NgZone.onMicrotaskEmpty, NgZone.onStable, NgZone.onUnstable |
| 7 | Replace NgZone.isStable guards with afterNextRender |
| 8 | Add ChangeDetectionStrategy.OnPush to all components |
| 9 | Update tests to use provideZonelessChangeDetection() and whenStable() |
Note: Zone.js is not going away. If you are still using zones, zone.js will continue to be supported. Zoneless is an opt-in, not a forced migration.
What You Gain
With Zone.js gone, stack traces are no longer wrapped in Zone-specific frames. You get a full, accurate stack trace that points exactly to where something happened — making debugging and profiling significantly easier.
Beyond that:
- Smaller bundles — ~10kB gzipped saved by removing Zone.js
- Faster rendering — change detection only runs for components that actually changed
- Predictable reactivity — you know exactly what triggers a UI update and when
- Better SSR compatibility — no Zone.js patching means cleaner server-side rendering
Further Reading
- Official Angular Zoneless Guide — angular.dev
- Angular Summer Update 2025 (v20.2 stable announcement) — Angular Blog
- provideZonelessChangeDetection API Reference — angular.dev
- Angular Signals Deep Dive — angular.dev
- How Angular 20.2 Replaces Zone.js for Better Performance — iJS Conference Blog
- The Latest in Angular Change Detection — angular.love
Zoneless Angular isn't just a performance micro-optimization — it's a fundamental shift in how the framework reasons about reactivity. With v20.2 marking the feature as stable, there's never been a better time to make the switch. Start small: enable the provider, add Signals to a single component, and watch your bundle shrink and your stack traces clear up. The rest follows naturally.
Read next
Comments (1)
Join the conversation
Sign in to leave a comment on this post.
Great breakdown of a genuinely important shift in Angular's architecture. The before/after counter example really crystallizes the mental model change — it's not just a syntax difference, it's a different way of thinking about who is responsible for scheduling updates. One thing worth emphasizing for teams considering migration: the toSignal() bridge is arguably the most practical tool in the whole transition. Most real-world apps are Observable-heavy, and being able to wrap existing RxJS streams without rewriting service layers makes the migration far less daunting than it first appears.