Angular 21 Zoneless: Dropping zone.js
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.
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
See the live comparison
View angular zoneless on PkgPulse →