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 .
| API | What it does |
|---|---|
ChangeDetectorRef.markForCheck() | Marks the component and its ancestors dirty |
ComponentRef.setInput() | Sets an input programmatically |
| Signal update | Updates a signal read in a template |
| Bound listener | A (click), (keydown), etc. on the component or its template |
| Attach/remove dirty view | A 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
| API | Purpose |
|---|---|
markForCheck() | The explicit notification |
setInput() | The programmatic input |
| Signal update | The automatic notification |
| Bound listener | The event |
| Attach/remove dirty view | The view lifecycle |
The Version Mapping
| Version | Enabling |
|---|---|
| v21+ | Default (no provider) |
| v20.2+ | provideZonelessChangeDetection() |
| v19 | provideExperimentalZonelessChangeDetection() |
The Migration Phases
| Phase | Action |
|---|---|
| 1 | Add the provider |
| 2 | Convert to signals + OnPush |
| 3 | Add the test provider |
| 4 | Remove the polyfill |
The Removed APIs
| API | Replacement |
|---|---|
NgZone.onMicrotaskEmpty | afterNextRender |
NgZone.onStable | afterEveryRender |
NgZone.isStable | Not needed |
The Test Setup
| Version | Approach |
|---|---|
| v20+ | providersFile in angular.json |
| Older | Custom test.ts with a module |
The Signal Mutation Rule
| Value | Trigger |
|---|---|
| Primitive | Value change |
| Array/object | Reference 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
| Pitfall | Problem | Solution |
|---|---|---|
| Plain property in setInterval | No trigger | Signal or markForCheck |
| Signal array mutation | No trigger | New reference |
onMicrotaskEmpty | Never emits | afterNextRender |
| Missing test provider | Test failures | Add the provider |
| Zone.js in polyfills | Bundle overhead | Remove it |
async/await without signal | No trigger | Signal or markForCheck |
| Manual subscribe | No trigger | async pipe or toSignal |
NgZone.isStable | Always true | Not 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
| Item | Value |
|---|---|
| Zoneless default | v21+ |
| v20 provider | provideZonelessChangeDetection() |
| v19 provider | provideExperimentalZonelessChangeDetection() |
| Notification | markForCheck, setInput, signal, listener |
| Removed | NgZone.onMicrotaskEmpty |
| Replacement | afterNextRender, afterEveryRender |
| Signal array | New reference required |
| Test provider | providersFile or TestBed |
| Payload saving | 36–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 isprovideExperimentalZonelessChangeDetection()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 —
setIntervalcallbacks, manual subscriptions, andasync/awaitcontinuations need a signal or amarkForCheck() - Signal-held arrays and objects need a new reference — mutating the contents does not trigger; returning a new array or object does
NgZone.onMicrotaskEmptyand related observables never emit — replace them withafterNextRenderorafterEveryRender- 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
TestBedor use aprovidersFile - 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!