| |

Angular 65 🅰️ Zoneless Angular

Zone.js has been the engine of Angular’s change detection for most of the framework’s life. It works by monkey-patching nearly every asynchronous API in the browser, and when the microtask queue empties, it fires a change detection cycle. That approach made Angular’s “just mutate a property and the view updates” ergonomics possible, but it came with real costs: a payload of 36–149 KB uncompressed (12–29 KB gzipped), startup overhead from the patching itself, stack traces that are harder to read, and a class of bugs that only appear when code runs outside the Angular zone . Zoneless Angular is the framework’s answer to those costs. It removes Zone.js entirely and replaces the broad interception with a set of explicit notification APIs — signals, ChangeDetectorRef.markForCheck(), ComponentRef.setInput(), and bound listeners. The result is a smaller bundle, a more precise scheduler, and a change detection model that is easier to reason about. This chapter covers what zoneless is, how to enable it, the notification APIs that replace Zone.js, the migration patterns, the testing setup, and the pitfalls that appear when Zone.js is no longer doing the work.

Key point: Zoneless change detection is the default in Angular v21+. New applications do not need provideZonelessChangeDetection() — they simply do not include Zone.js in their polyfills . For Angular v20 and earlier, the provideZonelessChangeDetection() provider enables it (stable since v20.2), and the older provideExperimentalZonelessChangeDetection() is the v19 name . When zoneless is enabled, Angular schedules change detection only when one of the following notifies it: a ChangeDetectorRef.markForCheck() call (which the async pipe does automatically), a ComponentRef.setInput() call, an update to a signal read in a template, a bound host or template listener, or the attach/remove of a view marked dirty by one of those . Plain property mutations, setInterval callbacks without signals, and manual subscribe calls without markForCheck() do not trigger change detection .


Why zoneless exists

The case for removing Zone.js is a combination of performance, debugging, and ecosystem compatibility.

The performance cost. Zone.js triggers change detection after any asynchronous operation, regardless of whether the application’s state actually changed. A setTimeout that updates an unrelated variable, an HTTP response that is discarded, a requestAnimationFrame that only animates a canvas — all of them cause Angular to run a full change detection cycle . Zoneless replaces that imprecise trigger with a precise one: Angular only runs change detection when something that could affect the view actually changed.

The payload cost. Zone.js adds 36–149 KB of uncompressed JavaScript (12–29 KB gzipped) to the bundle, plus the startup time to install its patches . Removing it is a direct bundle-size win with no application-code change.

The debugging cost. Zone.js patches the native APIs, which means stack traces pass through the patched wrappers. Understanding whether a piece of code is running inside or outside the Angular zone is a common source of confusion .

The ecosystem cost. Zone.js cannot patch async/await directly, so the Angular CLI has to downlevel it into a form that can be tracked. Libraries that rely on the unpatched APIs can break when Zone.js is present .

Why the default changed. Angular v21 makes zoneless the default for new applications. The Angular team’s migration tooling (onpush_zoneless_migration in the MCP server) helps existing applications move incrementally . Zone.js remains fully supported for applications that need it, and the migration is reversible .


Enabling zoneless

The way to enable zoneless depends on the Angular version.

Angular v21+. Zoneless is the default. No provider is needed. The only requirement is that provideZoneChangeDetection is not used to override the default, and that zone.js is not in the polyfills .

Angular v20. Add provideZonelessChangeDetection() to the bootstrap providers. It is stable as of v20.2 .

// main.ts — standalone bootstrap
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
});

Angular v19. Use provideExperimentalZonelessChangeDetection(). The API was experimental at that point .

Removing Zone.js. The polyfill must be removed from the build. In angular.json, remove zone.js and zone.js/testing from the polyfills array of both the build and test targets. If a polyfills.ts file exists, remove the import 'zone.js'; and import 'zone.js/testing'; lines. Then npm uninstall zone.js .


The notification APIs

Zoneless change detection relies on Angular’s own APIs to know when to run. The list is explicit and finite .

APIWhat it does
ChangeDetectorRef.markForCheck()Marks the component and its ancestors dirty
ComponentRef.setInput()Sets an input programmatically
Signal updateUpdates a signal read in a template
Bound listenerA (click), (keydown), etc. on the component or its template
Attach/remove dirty viewA view created or destroyed after being marked

Why signals are the natural fit. A signal that is read in a template is registered as a dependency. When the signal updates, Angular marks the component dirty automatically . This is the most precise trigger — it fires only when the value actually changes, and only for the components that read it.

Why async pipe still works. The async pipe calls markForCheck() internally when the observable emits . A template that uses {{ data$ | async }} is zoneless-compatible without any change.

Why markForCheck() is the escape hatch. For the cases where a value changes outside any of the above — a setTimeout callback, a manual subscription, a library callback — markForCheck() is the explicit notification. It schedules the check for the next cycle .


What breaks in zoneless

The migration is not free. The patterns that relied on Zone.js’s broad interception stop working.

The plain property mutation. A component that mutates a plain property in a setInterval callback and expects the view to update will not work. The callback runs, the property changes, and Angular does not know .

// Zoneless: does NOT trigger
setInterval(() => {
  this.count++;  // ❌
}, 1000);

The fix is to convert the property to a signal, or to call markForCheck() after the mutation .

The manual subscribe. A component that subscribes to an observable in ngOnInit and assigns the result to a plain property does not trigger change detection in zoneless .

// Zoneless: does NOT trigger
this.http.get('/api').subscribe((data) => {
  this.data = data;  // ❌
});

The fix is to use the async pipe, toSignal(), or to call markForCheck() in the subscription callback .

The NgZone.onMicrotaskEmpty and related observables. These never emit in zoneless. Code that waits for onMicrotaskEmpty or onStable to run a task must be replaced with afterNextRender() or afterEveryRender() .

The NgZone.isStable check. It is always true in zoneless and should not be used as a condition .

The non-signal object mutation. A signal that holds an array or object marks a change when the reference changes, not when the contents mutate. array.push() on a signal-held array does not trigger; a new array must be returned .

// Does NOT trigger
this.items().push(newItem);  // ❌

// Triggers
this.items.update((list) => [...list, newItem]);  // ✅

The migration path

The migration can be staged. The Angular team’s tooling and the community’s experience suggest a four-phase approach .

Phase 1: the config. Add provideZonelessChangeDetection() to the bootstrap providers. The application runs with zoneless scheduling, but Zone.js is still installed. This phase surfaces the components that rely on Zone.js for change detection without removing the safety net.

Phase 2: the components. Convert the state to signals where possible, add OnPush to the components that are not using it, and replace NgZone.run() calls with direct signal updates or markForCheck() .

Phase 3: the tests. Add provideZonelessChangeDetection() to the TestBed configuration. For older Angular versions, this can be set globally in a test-providers.ts file or a custom test.ts . Convert fakeAsync/tick patterns to async/await .

Phase 4: the polyfill. Remove zone.js from angular.json and polyfills.ts, and npm uninstall zone.js .

Why the phased approach matters. Each phase is independently testable, and the migration is reversible. If a regression appears, removing the provider and restoring the polyfill re-enables Zone.js-driven detection .


Testing in zoneless

The test environment must be configured to match production. If the application is zoneless, the tests should be too, or the tests may pass while production fails.

The TestBed provider. Add provideZonelessChangeDetection() to the TestBed providers .

TestBed.configureTestingModule({
  providers: [provideZonelessChangeDetection()],
});

The global setup. For Angular v20+, the unit-test builder accepts a providersFile option. Create src/test-providers.ts with the provider and reference it in angular.json .

// src/test-providers.ts
import { provideZonelessChangeDetection } from '@angular/core';
export default [provideZonelessChangeDetection()];

Why the test setup matters. A component that passes in a Zone.js test environment may fail in a zoneless production environment if it relies on implicit change detection. Running the tests zonelessly catches those gaps before deployment .


Complete Example Session

// ============================================
// PART 1: THE BOOTSTRAP (ANGULAR v20)
// ============================================

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection(),
    ...appConfig.providers,
  ],
});

// ============================================
// PART 2: THE SIGNAL-BASED COMPONENT
// ============================================

import { Component, signal, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-counter',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Count: {{ count() }}</p>
    <button (click)="increment()">+</button>
  `,
})
export class CounterComponent {
  count = signal(0);

  increment(): void {
    this.count.update((v) => v + 1);  // ✅ triggers
  }
}

// ============================================
// PART 3: THE PLAIN PROPERTY (BROKEN)
// ============================================

@Component({
  selector: 'app-broken',
  template: `<p>{{ value }}</p>`,
})
export class BrokenComponent {
  value = 0;

  constructor() {
    setInterval(() => {
      this.value++;  // ❌ does not trigger in zoneless
    }, 1000);
  }
}

// ============================================
// PART 4: THE MARKFORCHECK FIX
// ============================================

import { ChangeDetectorRef, inject } from '@angular/core';

@Component({
  selector: 'app-fixed',
  template: `<p>{{ value }}</p>`,
})
export class FixedComponent {
  private readonly cdr = inject(ChangeDetectorRef);
  value = 0;

  constructor() {
    setInterval(() => {
      this.value++;
      this.cdr.markForCheck();  // ✅ triggers
    }, 1000);
  }
}

// ============================================
// PART 5: THE ASYNC PIPE
// ============================================

@Component({
  selector: 'app-async',
  template: `<p>{{ data$ | async }}</p>`,
})
export class AsyncComponent {
  data$ = this.http.get('/api/data');
}

// ============================================
// PART 6: THE SIGNAL OBJECT MUTATION TRAP
// ============================================

@Component({
  selector: 'app-items',
  template: `
    @for (item of items(); track item.id) {
      <div>{{ item.name }}</div>
    }
  `,
})
export class ItemsComponent {
  items = signal<Item[]>([]);

  // ❌ does not trigger
  addWrong(item: Item): void {
    this.items().push(item);
  }

  // ✅ triggers
  addRight(item: Item): void {
    this.items.update((list) => [...list, item]);
  }
}

// ============================================
// PART 7: THE REMOVED ZONE APIS
// ============================================

// ❌ never emits in zoneless
this.zone.onMicrotaskEmpty.subscribe(() => { ... });

// ✅ replacement
import { afterNextRender } from '@angular/core';

afterNextRender(() => {
  // runs after the next change detection
});

// ============================================
// PART 8: THE TEST SETUP
// ============================================

// src/test-providers.ts
import { provideZonelessChangeDetection } from '@angular/core';
export default [provideZonelessChangeDetection()];

// angular.json
{
  "projects": {
    "app": {
      "architect": {
        "test": {
          "options": {
            "providersFile": "src/test-providers.ts"
          }
        }
      }
    }
  }
}

// ============================================
// PART 9: THE POLYFILL REMOVAL
// ============================================

// angular.json — remove zone.js from polyfills
{
  "polyfills": []  // no zone.js
}

// polyfills.ts — remove the imports
// import 'zone.js';        // ❌ remove
// import 'zone.js/testing'; // ❌ remove

// ============================================
// PART 10: WHAT NOT TO DO
// ============================================

// Don't forget the markForCheck for the async's
setTimeout(() => { this.value = 'x'; });  // ❌

// Don't mutate a signal-held array without a new reference
this.items().push(item);  // ❌

// Don't use the NgZone.onMicrotaskEmpty
this.zone.onMicrotaskEmpty.subscribe(...);  // ❌ never emits

// Don't forget the test provider
TestBed.configureTestingModule({});  // ❌ may fail

// Don't forget the polyfill removal
// zone.js is still in the bundle.

// Don't assume the async/await is tracked
await Promise.resolve(); this.value = 'x';  // ❌ in zoneless

The ten parts cover the bootstrap, the signal-based component, the broken plain property, the markForCheck fix, the async pipe, the signal object mutation trap, the removed zone APIs, the test setup, the polyfill removal, and the anti-patterns.


Quick Reference

The Notification APIs

APIPurpose
markForCheck()The explicit notification
setInput()The programmatic input
Signal updateThe automatic notification
Bound listenerThe event
Attach/remove dirty viewThe view lifecycle

The Version Mapping

VersionEnabling
v21+Default (no provider)
v20.2+provideZonelessChangeDetection()
v19provideExperimentalZonelessChangeDetection()

The Migration Phases

PhaseAction
1Add the provider
2Convert to signals + OnPush
3Add the test provider
4Remove the polyfill

The Removed APIs

APIReplacement
NgZone.onMicrotaskEmptyafterNextRender
NgZone.onStableafterEveryRender
NgZone.isStableNot needed

The Test Setup

VersionApproach
v20+providersFile in angular.json
OlderCustom test.ts with a module

The Signal Mutation Rule

ValueTrigger
PrimitiveValue change
Array/objectReference change

Best Practices

✅ Do This:

// Use signals for the state
count = signal(0);                                             // ✅
// Use the markForCheck for the async's
this.cdr.markForCheck();                                       // ✅
// Use the async pipe
{{ data$ | async }}                                            // ✅
// Use the immutable update for the arrays
this.items.update((list) => [...list, item]);                  // ✅
// Add the test provider
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] }); // ✅
// Use the afterNextRender
afterNextRender(() => { ... });                                // ✅
// Remove the polyfill
// angular.json: "polyfills": []                               // ✅

❌ Don’t Do This:

// Don't mutate a plain property in a setInterval
setInterval(() => { this.value++; });  // ❌                   // ⚠️
// Don't mutate a signal-held array without a new reference
this.items().push(item);  // ❌                                // ⚠️
// Don't use the NgZone.onMicrotaskEmpty
this.zone.onMicrotaskEmpty.subscribe(...);  // ❌              // ⚠️
// Don't forget the test provider
TestBed.configureTestingModule({});  // ❌                     // ⚠️
// Don't assume the async/await is tracked
await Promise.resolve(); this.value = 'x';  // ❌               // ⚠️
// Don't forget the polyfill removal
// The zone.js is still in the bundle.                         // ⚠️

Common Pitfalls

PitfallProblemSolution
Plain property in setIntervalNo triggerSignal or markForCheck
Signal array mutationNo triggerNew reference
onMicrotaskEmptyNever emitsafterNextRender
Missing test providerTest failuresAdd the provider
Zone.js in polyfillsBundle overheadRemove it
async/await without signalNo triggerSignal or markForCheck
Manual subscribeNo triggerasync pipe or toSignal
NgZone.isStableAlways trueNot needed

Real-World Examples

1. The signal state

count = signal(0);

2. The markForCheck

this.cdr.markForCheck();

3. The async pipe

{{ data$ | async }}

4. The immutable array update

this.items.update((list) => [...list, item]);

5. The test provider

TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });

6. The afterNextRender

afterNextRender(() => { ... });

7. The bootstrap (v20)

bootstrapApplication(AppComponent, { providers: [provideZonelessChangeDetection()] });

8. The polyfill removal

{ "polyfills": [] }

9. The providersFile

{ "providersFile": "src/test-providers.ts" }

10. The toSignal

readonly data = toSignal(this.http.get('/api'));

Visual: The Notification Flow

┌──────────────────────────────────────────────┐
│  THE NOTIFICATION                            │
│    │                                         │
│    ├── The signal's update                   │
│    ├── The markForCheck's                    │
│    ├── The setInput's                        │
│    ├── The bound's listener                  │
│    └── The attach/remove's                   │
│                                              │
│         │  The scheduler                     │
│         ▼                                    │
│  THE CHANGE DETECTION'S                      │
│    The component's and the ancestors's       │
│                                              │
└──────────────────────────────────────────────┘

Visual: The Zone.js vs Zoneless

┌──────────────────────────────────────────────┐
│  ZONE.JS                                     │
│    The patched APIs                          │
│    The broad's trigger's                     │
│    The 36-149 KB's                           │
│                                              │
├──────────────────────────────────────────────┤
│  ZONELESS                                    │
│    The Angular's APIs                        │
│    The precise's trigger's                   │
│    The no payload's                          │
│                                              │
│  The zoneless's is the modern's, and the     │
│  modern's is the precise's.                  │
│                                              │
└──────────────────────────────────────────────┘

Visual: The Signal Mutation Rule

┌──────────────────────────────────────────────┐
│  THE PRIMITIVE'S                             │
│    count = signal(0)                         │
│    count.set(1)  → ✅ triggers               │
│                                              │
├──────────────────────────────────────────────┤
│  THE ARRAY'S                                 │
│    items = signal<Item[]>([])                │
│    items().push(item)  → ❌ no trigger       │
│    items.set([...items(), item])  → ✅       │
│                                              │
│  The reference's is the trigger's, and the   │
│  reference's is the new's.                   │
│                                              │
└──────────────────────────────────────────────┘

Visual: The Migration’s Phases

┌──────────────────────────────────────────────┐
│  PHASE 1: THE CONFIG'S                       │
│    Add the provideZonelessChangeDetection.   │
│                                              │
│  PHASE 2: THE COMPONENT'S                    │
│    The signals's, the OnPush's, the          │
│    markForCheck's.                           │
│                                              │
│  PHASE 3: THE TEST'S                         │
│    Add the test provider.                    │
│                                              │
│  PHASE 4: THE POLYFILL'S                     │
│    Remove the zone.js.                       │
│                                              │
│  The four are the incremental's, and the     │
│  incremental's is the safe's.                │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Zoneless defaultv21+
v20 providerprovideZonelessChangeDetection()
v19 providerprovideExperimentalZonelessChangeDetection()
NotificationmarkForCheck, setInput, signal, listener
RemovedNgZone.onMicrotaskEmpty
ReplacementafterNextRender, afterEveryRender
Signal arrayNew reference required
Test providerprovidersFile or TestBed
Payload saving36–149 KB uncompressed

Key takeaways:

  • Zoneless is the default in Angular v21+ — new applications do not include Zone.js, and no provider is needed
  • For v20, add provideZonelessChangeDetection() — it is stable as of v20.2, and the older name is provideExperimentalZonelessChangeDetection() for v19
  • The notification APIs are explicit and finite — markForCheck(), setInput(), signal updates, bound listeners, and dirty view attach/remove
  • Signals are the natural fit — a signal read in a template is a dependency, and updating it marks the component dirty automatically
  • Plain property mutations do not trigger — setInterval callbacks, manual subscriptions, and async/await continuations need a signal or a markForCheck()
  • Signal-held arrays and objects need a new reference — mutating the contents does not trigger; returning a new array or object does
  • NgZone.onMicrotaskEmpty and related observables never emit — replace them with afterNextRender or afterEveryRender
  • The migration is staged and reversible — the config, the components, the tests, and the polyfill; restoring the polyfill re-enables Zone.js
  • The test environment must match production — add the zoneless provider to the TestBed or use a providersFile
  • The bundle saving is real — 36–149 KB uncompressed (12–29 KB gzipped) is removed when Zone.js is fully eliminated

Remember: Zoneless Angular replaces Zone.js’s broad interception with Angular’s own notification APIs. The trade is precision for explicitness: the application must tell Angular when to check, but it tells Angular only when something that matters actually changed. The migration is a config change, a component audit, a test update, and a polyfill removal. The result is a smaller bundle, a more precise scheduler, and a change detection model that is easier to debug. The signals are the foundation, and the explicit notifications are the safety net.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!