<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/angular-21-zoneless-zone-js-performance-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/angular-21-zoneless-zone-js-performance-2026/raw.md -->
<!-- Source path: content/guides/angular-21-zoneless-zone-js-performance-2026.mdx -->

---
og_image: "/images/guides/angular-21-zoneless-zone-js-performance-2026.webp"
title: "Angular 21 Zoneless: Dropping zone.js 2026"
description: "Angular 21 zoneless change detection explained: what zone.js was doing, how signals replace it, performance benchmarks, migration path, and what breaks."
date: "2026-03-16"
author: "PkgPulse Team"
tags: ["angular", "angular-21", "zoneless", "zonejs", "signals", "performance", "2026"]
featured_comparison: "angular-zoneless"
---

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

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

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

```typescript
// 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:

```typescript
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:

```typescript
// 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),
  ]
}
```

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

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

```typescript
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

```typescript
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

```typescript
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:

```typescript
// ❌ 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:

```typescript
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:

```typescript
// ❌ 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:

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

```bash
# 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](https://www.pkgpulse.com).*

*Related: [Angular vs React vs Vue 2026](/guides/angular-vs-react-vs-vue-frontend-2026) · [SolidJS vs Svelte 5 vs React Reactivity](/guides/solidjs-vs-svelte-5-vs-react-reactivity-2026) · [ECMAScript 2026 Features](/guides/ecmascript-2026-new-javascript-features)*
