Angular has been dragging a monkey-patching library into every application since 2016. Zone.js intercepted every setTimeout, every Promise, every DOM event — the price you paid for automatic change detection. As of Angular 21, zoneless is the default for new projects. The provideZonelessChangeDetection() API became stable in Angular 20.2; Angular 21 simply stopped requiring it for new apps. Here's what changes, what breaks, and whether you should migrate.
TL;DR
Angular 21 makes zoneless the default for new projects — no provider configuration needed. provideZonelessChangeDetection() graduated to stable in Angular 20.2; Angular 21 simply removed it from the boilerplate. Dropping zone.js removes ~33KB from your bundle, reduces rendering overhead by 30-40%, and eliminates unnecessary change detection cycles. New projects: zoneless out of the box. Existing apps: careful migration with signals replacing zone-triggered patterns.
Key Takeaways
- zone.js costs: ~33KB bundle, monkey-patches browser async APIs, triggers full tree CD after every async callback
- Angular Signals:
signal(),computed(),effect()— all stable since Angular 20; the reactive layer that makes zoneless possible provideZonelessChangeDetection(): stable since Angular 20.2 (previouslyprovideExperimentalZonelessChangeDetection()from Angular 18); Angular 21 = default for new apps, no call needed- OnPush becomes default: in zoneless mode, components only update when signals change or
markForCheck()is called - What breaks: components mutating state without signals,
ErrorHandlerasync visibility, tests usingfakeAsync/tick - Performance: 30-40% rendering improvement; ~33KB bundle savings; 15-20% memory reduction
At a Glance
| Aspect | Zone.js (Classic) | Zoneless (Angular 21) |
|---|---|---|
| Bundle size impact | +~33KB (zone.js) | 0 (removed) |
| Change detection trigger | Automatic (zone tasks) | Signals / explicit marking |
setTimeout detection | ✅ auto | Requires signal update |
| DOM event detection | ✅ auto | ✅ auto (event bindings still work) |
| Promise/fetch detection | ✅ auto | Requires signal update |
| Performance overhead | 2-5ms/CD cycle | Minimal (signal-only) |
| Debugging complexity | High (zone stack traces) | Lower |
| OnPush required | Optional | Effectively default |
| Library compatibility | Universal | Audit required |
What Zone.js Was Doing
Zone.js works by monkey-patching almost every asynchronous API in the browser:
// Zone.js wraps these APIs at startup (simplified):
const originalSetTimeout = window.setTimeout
window.setTimeout = function(fn, delay) {
return originalSetTimeout(() => {
// Mark Angular zone as active
zone.run(() => {
fn()
// After fn runs, trigger Angular change detection
applicationRef.tick()
})
}, delay)
}
// Same for: setInterval, Promise, queueMicrotask,
// requestAnimationFrame, fetch, XMLHttpRequest,
// addEventListener, removeEventListener, and 200+ more
The logic: "if any async operation runs, Angular component state may have changed — re-run change detection across the entire app." This is powerful but blunt. Even a timer callback that changes zero component state triggers a full change detection cycle.
The Performance Problem
// This Angular 4-19 code triggers change detection
// every time the timer fires — even if no component reads `count`
@Component({ template: `<p>{{ visibleData }}</p>` })
export class AppComponent {
visibleData = 'unchanged'
ngOnInit() {
// Zone.js sees the setTimeout, runs Angular CD on every tick
setInterval(() => {
// Even this no-op fires change detection
console.log('tick')
}, 100)
}
}
At scale: an enterprise Angular app with 50 components, each with a few timers or WebSocket handlers, could fire hundreds of unnecessary change detection cycles per second. Zone.js fixed Angular's developer ergonomics problem (no manual detectChanges() calls) but created a performance ceiling.
Angular Signals: The Foundation of Zoneless
Angular Signals (introduced Angular 16, all core APIs stable since Angular 20) provide the reactive primitive layer that makes zoneless possible:
import { signal, computed, effect } from '@angular/core'
// signal(): reactive state
const count = signal(0)
const name = signal('Angular')
// Read: count() — calling the signal function returns its value
console.log(count()) // 0
// Write: count.set() or count.update()
count.set(5)
count.update(n => n + 1) // 6
// computed(): derived reactive values (lazy, memoized)
const doubled = computed(() => count() * 2)
console.log(doubled()) // 12
// effect(): runs when signal dependencies change
effect(() => {
console.log(`Count is now: ${count()}`)
})
count.set(10) // triggers effect → "Count is now: 10"
In a zoneless app, Angular's change detection watches signals directly. When a signal in a template changes, only that component (and its children) re-render. No zone task, no global tick.
Enabling Zoneless in Angular 21
New Projects (Angular 21+)
Angular 21 CLI scaffolds zoneless apps by default. ng new my-app produces a project with no zone.js in polyfills.ts and no provideZonelessChangeDetection() call required — it's baked in.
For Angular 20 projects, opt in explicitly:
// app.config.ts (Angular 20 — explicit opt-in)
import { ApplicationConfig } from '@angular/core'
import { provideZonelessChangeDetection } from '@angular/core'
import { provideRouter } from '@angular/router'
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(), // stable since Angular 20.2
provideRouter(routes),
]
}
// angular.json — remove zone.js polyfill (Angular 20 opt-in or existing apps)
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": [
// Remove: "zone.js"
]
}
}
}
}
}
}
Existing Projects
// Step 1: Install and enable zoneless
// app.config.ts
providers: [
provideZonelessChangeDetection(),
]
// Step 2: Remove zone.js from polyfills in angular.json
// "polyfills": ["zone.js"] → "polyfills": []
// Step 3: Run the app, identify broken components
// (components that don't update when they should)
// Step 4: Migrate broken components to signals or explicit marking
Writing Zoneless Components
The Signals-First Pattern
import { Component, signal, computed, OnInit } from '@angular/core'
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<p>Doubled: {{ doubled() }}</p>
<button (click)="increment()">+</button>
`,
// changeDetection: ChangeDetectionStrategy.OnPush ← not required in zoneless
// but still recommended as a best practice
})
export class CounterComponent {
count = signal(0)
doubled = computed(() => this.count() * 2)
increment() {
this.count.update(n => n + 1)
// Angular sees the signal changed → re-renders this component only
}
}
Async Operations Without Zone.js
import { Component, signal, inject, OnInit } from '@angular/core'
import { HttpClient } from '@angular/common/http'
@Component({
selector: 'app-users',
template: `
@if (loading()) {
<p>Loading...</p>
} @else {
@for (user of users(); track user.id) {
<p>{{ user.name }}</p>
}
}
`
})
export class UsersComponent implements OnInit {
private http = inject(HttpClient)
users = signal<User[]>([])
loading = signal(true)
ngOnInit() {
this.http.get<User[]>('/api/users').subscribe({
next: (data) => {
this.users.set(data) // signal.set() triggers CD
this.loading.set(false)
},
error: () => this.loading.set(false)
})
}
}
HttpClient and async pipe still work in zoneless — they use Angular's internal scheduling, not zone.js tasks.
Using toSignal() for RxJS
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { interval } from 'rxjs'
import { map } from 'rxjs/operators'
@Component({
selector: 'app-clock',
template: `<p>Time: {{ time() }}</p>`
})
export class ClockComponent {
time = toSignal(
interval(1000).pipe(map(() => new Date().toLocaleTimeString())),
{ initialValue: new Date().toLocaleTimeString() }
)
// toSignal wraps the observable in a signal — zone.js not needed
}
What Breaks Without Zone.js
Mutations Outside Signals
The most common break:
// ❌ Breaks in zoneless — mutation without signal
@Component({
template: `<p>{{ title }}</p>`
})
export class BrokenComponent {
title = 'initial'
async loadData() {
const data = await fetch('/api').then(r => r.json())
this.title = data.title // mutation — zone.js would detect this; zoneless won't
// Component won't re-render!
}
}
// ✅ Fix: use a signal
@Component({
template: `<p>{{ title() }}</p>`
})
export class FixedComponent {
title = signal('initial')
async loadData() {
const data = await fetch('/api').then(r => r.json())
this.title.set(data.title) // signal.set() → triggers CD
}
}
ChangeDetectorRef.markForCheck() Still Works
For components not yet migrated to signals:
import { Component, ChangeDetectorRef, inject } from '@angular/core'
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ title }}</p>`
})
export class LegacyComponent {
title = 'initial'
private cdr = inject(ChangeDetectorRef)
async loadData() {
const data = await fetch('/api').then(r => r.json())
this.title = data.title
this.cdr.markForCheck() // explicitly tell Angular to re-check this component
}
}
This is the bridge pattern for gradual migration — use markForCheck() in components you haven't migrated to signals yet.
ErrorHandler Loses Async Visibility
One non-obvious break: Angular's ErrorHandler loses visibility into async errors when zone.js is removed. Zone.js was capturing uncaught Promise rejections and setTimeout errors inside the Angular zone. Without it:
// ❌ This async error is silent without zone.js:
setTimeout(() => {
throw new Error('oops') // Not caught by Angular's ErrorHandler
}, 1000)
// ✅ Fix: add window-level listeners alongside your ErrorHandler
window.addEventListener('unhandledrejection', (event) => {
// Handle unhandled Promise rejections
myErrorService.report(event.reason)
})
window.addEventListener('error', (event) => {
// Handle uncaught errors in async contexts
myErrorService.report(event.error)
})
Add these listeners in your app root or alongside your ErrorHandler implementation when going zoneless.
Testing Changes: fakeAsync → Vitest Timers
If you're using fakeAsync/tick in tests, those relied on zone.js's timer patching. Angular 21 ships Vitest as the default test runner with native fake timer support:
// Before (zone.js fakeAsync — Angular 20 and earlier):
it('updates after timer', fakeAsync(() => {
component.startTimer()
tick(1000)
expect(component.count()).toBe(1)
}))
// After (Vitest fake timers — Angular 21):
it('updates after timer', async () => {
vi.useFakeTimers()
component.startTimer()
vi.advanceTimersByTime(1000)
await fixture.whenStable()
expect(component.count()).toBe(1)
vi.useRealTimers()
})
Third-Party Library Compatibility
Many popular Angular libraries have been updated for zoneless, but some still require zone.js:
Angular Material: ✅ zoneless-compatible (Angular 17+)
NgRx: ✅ zoneless-compatible (v18+)
Angular CDK: ✅ zoneless-compatible
NgRx Component Store: ✅ (use toSignal())
RxAngular: ✅ designed for zoneless
PrimeNG: ⚠️ partial (check version)
AG Grid Angular: ⚠️ check version >= 31+
Legacy libraries: ❌ likely require zone.js or manual patches
Auditing Your Dependencies
# Find libraries that import from zone.js
grep -r "zone.js" node_modules --include="*.js" -l | grep -v "\.min\." | head -20
If a library imports zone.js/dist/zone directly, it may need zone.js to remain in the polyfills.
Performance Impact
Removing zone.js has measurable performance benefits:
Benchmark results (Angular 21 zoneless vs zone.js):
Initial bundle size:
With zone.js: +~33KB (zone.js v0.14)
Without zone.js: 0 additional
Runtime rendering performance:
Zone.js mode: 30-40% slower (full tree traversal per async event)
Zoneless: 30-40% faster rendering (signal-only targeted updates)
Initial load time (enterprise apps):
Zone.js: baseline
Zoneless: ~12% improvement (2025 enterprise benchmarks)
Memory:
Zone.js: baseline
Zoneless: 15-20% less memory usage
Combined bundle reduction (real-world apps):
Average: ~18% total bundle size improvement
The gains compound: a zone.js app with 50 timers and WebSocket messages triggers change detection on every async callback. In zoneless, none of those trigger CD unless a signal changes.
Library Ecosystem Migration Patterns
Third-party Angular library compatibility with zoneless mode requires understanding what each library was using zone.js for. Libraries fall into three categories: those that used zone.js for nothing (work without modification), those that used zone.js's NgZone service to trigger change detection (need updating), and those that directly imported and relied on zone.js's monkey-patching behavior (require significant work or alternatives).
Angular Material, Angular CDK, and NgRx are all zoneless-compatible in their recent versions because they've been progressively adopting signals-based reactivity and explicit ChangeDetectorRef usage since Angular 17. When Material components update their internal state (a date picker closes, a dialog opens), they use markForCheck() rather than relying on zone.js to detect the state change. Updating to the latest version of these libraries before attempting zoneless migration is strongly recommended — older versions of Material and CDK do require zone.js.
RxJS-based state management libraries that use NgZone.run() to schedule emissions back into the Angular zone need updates. zone.js provided a convenient mechanism for forcing RxJS observable emissions to trigger change detection in Angular — wrapping an observable's next/error/complete callbacks in zone.run() was a common pattern. Without zone.js, these must use Angular's built-in scheduler or signals. The takeUntilDestroyed() operator and toSignal() function from @angular/core/rxjs-interop are the idiomatic replacements that work correctly in zoneless mode.
For legacy libraries that cannot be updated — particularly older AngularJS-era components migrated to Angular — a hybrid approach is available: keep zone.js in polyfills.ts while also adding provideZonelessChangeDetection(). This runs both change detection systems simultaneously (zone.js for components that need it, signal-based for new components), allowing gradual migration without requiring all libraries to be zoneless-compatible simultaneously.
Angular 21: What Else Changed
Zoneless was the headline for Angular 21 (released November 20, 2025), but the full release included:
- Zoneless default for new apps — CLI generates apps without zone.js;
provideZonelessChangeDetection()had already gone stable in Angular 20.2 - Signal Forms (experimental) — a new signals-native forms API replacing
FormGroup/FormControlwith composable signal-based primitives; reduces boilerplate significantly - Vitest as default test runner — Karma fully removed from CLI defaults; Vitest replaces it with native timer APIs and better async testing (note:
fakeAsync/ticktest patterns need updating) @angular/ariapackage — new accessibility package implementing WAI-ARIA patterns: 8 UI patterns, 13 components, keyboard interactions, focus management- Angular MCP Server (stable) — gives AI agents structured access to Angular tooling including
onpush_zoneless_migrationfor automated migration analysis - Tailwind CSS integration — CLI scaffolding includes bundled Tailwind configuration by default
defineoption forng serve— variable replacement now available during dev serve (was build-only since v17.2)
Migration Checklist
□ Replace zone.js in polyfills with provideZonelessChangeDetection()
□ Audit components: find any that mutate properties without signals
□ Convert mutable class properties to signal() where components update async
□ Replace $event binding side effects with signal updates
□ Test third-party libraries for zoneless compatibility
□ Add ChangeDetectorRef.markForCheck() as bridge for unmigrated components
□ Enable OnPush on all components (signals + OnPush = maximum performance)
□ Remove zone.js from package.json once all components validated
□ Run ng lint to catch remaining zone.js patterns
When to Go Zoneless
Go zoneless now if:
- Starting a new Angular 21+ project
- Your app has significant timer/WebSocket/background polling logic
- Bundle size is a concern (save ~33KB from zone.js + startup overhead)
- You're already using Angular Signals in OnPush components
Wait if:
- You have many third-party Angular libraries not yet zoneless-compatible
- Large legacy codebase with 100+ components — migration effort is significant
- Your team isn't familiar with signals — learn signals first, then remove zone.js
Track Angular and zone.js npm download trends on PkgPulse.
Related: Angular vs React vs Vue 2026 · SolidJS vs Svelte 5 vs React Reactivity · ECMAScript 2026 Features