Skip to main content

Guide

Angular 21 Zoneless: Dropping zone.js 2026

Angular 21 zoneless change detection explained: what zone.js was doing, how signals replace it, performance benchmarks, migration path, and what breaks.

·PkgPulse Team·
0

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.


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/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

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.