Skip to main content

Angular 21 Zoneless: Dropping zone.js

·PkgPulse Team

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 (previously provideExperimentalZonelessChangeDetection() 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, ErrorHandler async visibility, tests using fakeAsync/tick
  • Performance: 30-40% rendering improvement; ~33KB bundle savings; 15-20% memory reduction

At a Glance

AspectZone.js (Classic)Zoneless (Angular 21)
Bundle size impact+~33KB (zone.js)0 (removed)
Change detection triggerAutomatic (zone tasks)Signals / explicit marking
setTimeout detection✅ autoRequires signal update
DOM event detection✅ auto✅ auto (event bindings still work)
Promise/fetch detection✅ autoRequires signal update
Performance overhead2-5ms/CD cycleMinimal (signal-only)
Debugging complexityHigh (zone stack traces)Lower
OnPush requiredOptionalEffectively default
Library compatibilityUniversalAudit 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/FormControl with 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/tick test patterns need updating)
  • @angular/aria package — 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_migration for automated migration analysis
  • Tailwind CSS integration — CLI scaffolding includes bundled Tailwind configuration by default
  • define option for ng 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

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.