๐ The End of Zone.js: What Angular's Zoneless Default Means for Your App
Smaller bundles. Faster rendering. Cleaner code. The complete guide to Angular's zoneless change detection
Hi ๐, I'm Tushar Patil. Currently I am working as Frontend Developer (Angular) and also have expertise with .Net Core and Framework.
๐ค What even is Zone.js โ and why did we need it?
If you've been building Angular apps since the early days, you've probably never had to think about Zone.js. It just worked. But understanding what it was doing silently in the background is the key to appreciating why removing it is such a big deal.
Zone.js is a library that monkey-patches the browser. Every time you wrote setTimeout(), setInterval(), made an HTTP request, clicked a button, or resolved a Promise โ Zone.js was intercepting that call. It wrapped every async operation in a "zone" so Angular could be notified: "Hey, something just happened. You might want to check if the UI needs to update."
That notification kicked off change detection โ Angular's process of scanning the component tree and updating the DOM to match the latest state.
// What Zone.js was doing behind the scenes (simplified)
// It patched the global setTimeout like this:
const originalSetTimeout = window.setTimeout;
window.setTimeout = function(callback, delay) {
return originalSetTimeout(() => {
callback();
triggerAngularChangeDetection(); // ๐ Zone.js adding this automatically
}, delay);
};
This was clever in 2016. Developers didn't have to think about when to update the UI โ Angular figured it out automatically. But as apps grew larger and more complex, the cracks started showing. ๐
๐ฉ The problems with Zone.js
1. It was eager โ sometimes too eager. Every async event triggered a full change detection cycle, even if nothing actually changed. A WebSocket message, a setTimeout callback, a third-party library using setInterval โ all of them would wake Angular up unnecessarily.
2. It polluted stack traces. If you've ever debugged an Angular app and seen confusing zone.js frames all over your console, this was why. Every call stack had Zone.js fingerprints making errors harder to trace. ๐
3. It had compatibility headaches. Zone.js patches browser APIs at load time. New APIs like async/await required special handling. Some third-party libraries assumed Zone.js was present; others were confused by it. Edge cases accumulated over the years.
4. It added ~33KB to every bundle. Not enormous by itself, but when you're optimizing for Core Web Vitals, every kilobyte counts. ๐ฆ
โจ Enter Zoneless: Angular's new change detection model
Instead of relying on Zone.js to guess when the UI might need updating, zoneless Angular relies on explicit signals. Change detection now only fires when:
- โ A Signal value changes
- โ
A template event binding fires (e.g.
(click)) - โ
An
asyncpipe resolves a new value - โ
You explicitly call
markForCheck()
That's it. Angular no longer wakes up for every setTimeout or network response. It only runs change detection when it actually has a reason to. ๐ฏ
Here's the timeline of how this feature arrived:
| Version | Milestone |
|---|---|
| Angular 18 | provideZonelessChangeDetection() added โ experimental |
| Angular 20 | Signals API fully stable โ |
| Angular 20.2 | Zoneless promoted to stable โ |
| Angular 21 | Zoneless is the default for new projects ๐ |
โก The performance benefits โ by the numbers
Benchmark results comparing Angular 21 zoneless against Zone.js show that the zone.js package adds approximately 33KB to the bundle, while zoneless mode eliminates this entirely. Runtime rendering performance is 30โ40% faster with zoneless, since only signal-driven targeted updates occur rather than full tree traversal per async event. In enterprise apps, initial load time improves by around 12%, and memory usage drops by 15โ20%. Combined, real-world apps see an average of roughly 18% total bundle size improvement.
These gains compound significantly: a zone.js app with 50 timers and WebSocket messages triggers change detection on every async callback. In zoneless, none of those trigger change detection unless a signal actually changes.
Beyond raw numbers, there are three developer experience wins:
๐ Cleaner stack traces. Without Zone.js wrapping every call, error stack traces now reflect your actual code โ no more mysterious zone.js:XXX frames.
๐ง Predictability. You always know why the UI updated. It's because a signal changed or an event fired โ not because some async operation happened somewhere in the app.
๐ง Better ecosystem compatibility. Since Zone.js patches browser APIs, it sometimes struggled to keep up with new APIs or modern JavaScript features like async/await, which required special handling. Eliminating Zone.js removes this layer of complexity, leading to better long-term maintainability and fewer compatibility headaches.
๐๏ธ New projects: zoneless out of the box
If you're starting a fresh Angular 21 project, you don't need to do anything. Angular 21 introduces zoneless as the default for new applications โ the CLI generates apps without zone.js.
No provideZonelessChangeDetection() call needed. No zone.js in polyfills. Just a leaner, faster app from the very first ng new. ๐
Your new main.ts looks clean:
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
// No zone.js anywhere ๐
bootstrapApplication(AppComponent, appConfig);
And your app.config.ts already includes zoneless by default:
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(), // โ
already here
provideRouter([]),
],
};
๐ง Migrating an existing app to zoneless
This is the important part for most of you. If you're running Angular 17, 18, 19, or 20, here's how to migrate. ๐
Step 1: Upgrade to Angular 21
ng update @angular/core @angular/cli
Run ng update and follow the prompts. Always update with a clean git tree so you can isolate any issues.
๐ก Pre-flight checks: Make sure you're on Node.js 18.19+, all your tests pass on the current version, and you've reviewed
package.jsonfor any deprecated packages before upgrading.
Step 2: Run the automated migration schematic
Angular ships an onpush_zoneless_migration tool that analyses code and produces a recommended migration plan. Run it:
ng generate @angular/core:zoneless-migration
This schematic scans your codebase and:
- Adds
provideZonelessChangeDetection()to your app config - Flags components that need attention
- Identifies uses of
NgZoneAPIs that need replacing
Step 3: Enable zoneless change detection
In your app.config.ts (standalone) or app.module.ts (module-based):
// Standalone (app.config.ts)
import { provideZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(), // ๐ add this
],
};
// Module-based (app.module.ts)
import { provideZonelessChangeDetection } from '@angular/core';
@NgModule({
providers: [
provideZonelessChangeDetection(), // ๐ add this
],
})
export class AppModule {}
Step 4: Remove Zone.js from your build
Zoneless applications should remove Zone.js entirely from the build to reduce bundle size. Zone.js is typically loaded via the polyfills option in angular.json, both in the build and test targets. Remove zone.js and zone.js/testing from both to remove it from the build.
In angular.json, find and remove zone.js from both build and test targets:
// angular.json โ BEFORE โ
"polyfills": ["zone.js"]
// angular.json โ AFTER โ
"polyfills": []
If you have a polyfills.ts file, remove these two lines:
// Remove these โ
import 'zone.js';
import 'zone.js/testing';
Then uninstall zone.js entirely:
npm uninstall zone.js
Step 5: Replace NgZone APIs
Applications and libraries need to remove uses of NgZone.onMicrotaskEmpty, NgZone.onUnstable, and NgZone.onStable. These observables will never emit when an application enables zoneless change detection. NgZone.isStable will always be true and should not be used as a condition for code execution.
Here are the common replacements:
// โ BEFORE: Waiting for Angular to finish rendering
this.ngZone.onStable.pipe(take(1)).subscribe(() => {
doSomethingAfterRender();
});
// โ
AFTER: Use afterNextRender
import { afterNextRender } from '@angular/core';
afterNextRender(() => {
doSomethingAfterRender();
});
// โ BEFORE: Running code inside Angular's zone
this.ngZone.run(() => {
this.mySignal.set(newValue);
});
// โ
AFTER: Just set the signal directly โ no zone needed
this.mySignal.set(newValue);
// โ BEFORE: Running code outside Angular's zone (to avoid CD)
this.ngZone.runOutsideAngular(() => {
setInterval(() => trackMetrics(), 1000);
});
// โ
AFTER: Timers never trigger CD in zoneless โ just run it
setInterval(() => trackMetrics(), 1000);
Step 6: Move toward OnPush and Signals
The OnPush change detection strategy is not required, but it is a recommended step towards zoneless compatibility for application components.
For best performance, update your components to use OnPush and Signals:
// โ
Zoneless-optimized component
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush, // ๐ add this
template: `
<h2>{{ fullName() }}</h2>
<p>{{ email() }}</p>
`
})
export class UserCardComponent {
firstName = signal('');
lastName = signal('');
email = signal('');
// Computed signal โ updates only when firstName or lastName changes
fullName = computed(() => `\({this.firstName()} \){this.lastName()}`);
}
โ ๏ธ Note on HTTP responses: In zoneless, assigning an HTTP response to a plain property won't trigger change detection. Always assign to a
signal()or useresource()/httpResource():
// โ Won't trigger change detection in zoneless
this.user = await fetchUser();
// โ
Use a signal โ change detection fires automatically
this.user = signal<User | null>(null);
// Then in the loader:
this.user.set(await fetchUser());
// Or even better โ use httpResource()
user = httpResource(() => `/api/users/${this.userId()}`);
๐งช Updating your tests
TestBed uses Zone-based change detection by default when zone.js is loaded via the polyfills. If zone.js is not present, TestBed runs zoneless by default. To force zoneless mode when zone.js is loaded, add provideZonelessChangeDetection() to the test module providers.
// Your tests โ add provideZonelessChangeDetection()
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
describe('UserCardComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(), // ๐ add this
],
});
});
it('should display full name', async () => {
const fixture = TestBed.createComponent(UserCardComponent);
await fixture.whenStable(); // ๐ prefer this over fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Alice Johnson');
});
});
๐ก To ensure tests have the most similar behavior to production code, avoid using
fixture.detectChanges()when possible โ this forces change detection to run when Angular might otherwise not have scheduled it. Tests should allow Angular to handle when to synchronize state.
๐ง Common pitfalls and how to avoid them
โ ๏ธ Error NG0914: zone.js still loaded
If you see this error, you missed removing zone.js from your polyfills. Double-check angular.json and polyfills.ts.
โ ๏ธ Third-party library compatibility
Not all Angular libraries support zoneless architecture yet. Angular Material and CDK provide zoneless support in Angular 21, but community libraries may lag behind. Before migrating production applications, verify that critical dependencies support zoneless change detection.
If a library isn't compatible, you can temporarily use a hybrid approach:
// Hybrid: keep zone.js in polyfills.ts AND add zoneless provider
// This runs both systems simultaneously โ a safe bridge for gradual migration
providers: [
provideZonelessChangeDetection(),
]
// AND keep zone.js in polyfills for the incompatible library
This hybrid approach allows gradual migration without requiring all libraries to be zoneless-compatible simultaneously.
โ ๏ธ Plain class properties won't trigger change detection
This is the most common gotcha. In Zone.js, mutating a property would often "just work" because Zone.js would trigger CD after the async operation. In zoneless, it won't:
// โ This won't update the template in zoneless
this.items = [...this.items, newItem];
// โ
Use a signal
items = signal<Item[]>([]);
addItem(newItem: Item) {
this.items.update(list => [...list, newItem]);
}
๐ Migration checklist
- ๐ Upgrade to Angular 21 with
ng update - ๐ค Run
ng generate @angular/core:zoneless-migration - โ๏ธ Add
provideZonelessChangeDetection()to app config - ๐๏ธ Remove
zone.jsandzone.js/testingfromangular.jsonpolyfills - ๐ Remove
import 'zone.js'frompolyfills.ts(if present) - ๐ฆ Uninstall:
npm uninstall zone.js - ๐ Replace
NgZone.run()with direct signal updates - ๐ Replace
NgZone.onStablewithafterNextRender() - ๐ Replace
NgZone.runOutsideAngular()โ no longer needed - ๐ก Replace plain property assignments with
signal()orresource() - ๐งช Add
provideZonelessChangeDetection()to test modules - โ Check third-party library compatibility
- ๐ Run full test suite and verify bundle size improvement
๐ฏ Should you migrate right now?
If you're starting a new project โ yes, immediately. Angular 21 is zoneless by default, and you'd have to actively add Zone.js back to go against the grain.
If you're on an existing app โ plan it for your next upgrade cycle. You don't have to do it all at once. Start with provideZonelessChangeDetection() enabled but keep zone.js for now, then chip away at NgZone usages and plain property bindings one component at a time.
If you're already using Signals heavily โ you'll feel the benefits most. The more signal-driven your state is, the smoother and faster the migration will be. ๐ช
Zoneless Angular isn't just a performance optimization. It's the final piece of a new mental model: state is explicit, updates are intentional, and Angular does exactly what you ask it to do โ nothing more. ๐
