Skip to main content

Command Palette

Search for a command to run...

๐Ÿš€ 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

Published
โ€ข11 min read
๐Ÿš€ The End of Zone.js: What Angular's Zoneless Default Means for Your App
T

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 async pipe 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.json for 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 NgZone APIs 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 use resource() / 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.js and zone.js/testing from angular.json polyfills
  • ๐Ÿ“„ Remove import 'zone.js' from polyfills.ts (if present)
  • ๐Ÿ“ฆ Uninstall: npm uninstall zone.js
  • ๐Ÿ”„ Replace NgZone.run() with direct signal updates
  • ๐Ÿ”„ Replace NgZone.onStable with afterNextRender()
  • ๐Ÿ”„ Replace NgZone.runOutsideAngular() โ€” no longer needed
  • ๐Ÿ“ก Replace plain property assignments with signal() or resource()
  • ๐Ÿงช 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. ๐Ÿš€


๐Ÿ“š Further reading