Zoneless Angular: The Definitive Guide to Understanding the Biggest Change in Years
I've been playing with Angular v22 and realized something: if your project still depends on Zone.js, you're leaving performance on the table. Seriously. Zoneless has been the default behavior since Angular v21, and if you haven't migrated, you're missing significant improvements in performance, debugging, and compatibility.
Let's break down what this means and how to make the transition without having a heart attack.
What Was Zone.js and Why It's Gone
Zone.js was the invisible engine that powered change detection in Angular. It worked by patching browser APIs: every time a DOM event, setTimeout, or Promise occurred, Zone.js told Angular: "Hey, something happened, check if anything changed."
The problem? It's like having a security guard who yells "FIRE!" every time someone lights a candle. Zone.js didn't know if there was actually a state change, only that something asynchronous had happened. Result: unnecessary change detection, over and over again.
"The main advantages to removing ZoneJS as a dependency are: Improved performance, Improved Core Web Vitals, Improved debugging experience, Better ecosystem compatibility" - Source: https://angular.dev/guide/zoneless
The 4 Concrete Benefits
1. Real Performance
Without Zone.js, Angular only runs change detection when there's an actual notified state change. This translates to fewer render cycles and a smoother app.
2. Better Core Web Vitals
Zone.js adds overhead in both payload size and startup time. Eliminating it improves metrics like LCP and TTI directly.
3. Clean Debugging
Stack traces with Zone.js are a headache. Without it, errors point directly to your code, not to the internal patch layers.
4. Ecosystem Compatibility
Zone.js patches browser APIs, but it can't patch everything. async/await has to be transformed to work with it. Third-party libraries sometimes break due to the patches. Without Zone.js, these problems disappear.
How Change Detection Works Without Zone
Angular now depends on explicit notifications to know when to run change detection:
- Signals: when you update a signal read in a template, Angular knows it needs to update.
- markForCheck(): still works for code that doesn't use signals.
- AsyncPipe: remains compatible.
- Template event listeners: event callbacks in the template notify automatically.
"Angular relies on notifications from core APIs in order to determine when to run change detection and on which views" - Source: https://angular.dev/guide/zoneless
Step-by-Step Migration
Step 1: Verify Your Version
Zoneless is the default in Angular v21+. If you're on v22, you just need to verify no one has overridden the configuration:
// main.ts - this should NOT exist in v22
bootstrapApplication(App, {
providers: [
provideZoneChangeDetection(), // IF THIS EXISTS, REMOVE IT
],
});
Step 2: Remove Zone.js from the Build
// angular.json - look for "polyfills"
{
"projects": {
"your-app": {
"architect": {
"build": {
"options": {
"polyfills": [] // Remove "zone.js" from here
}
},
"test": {
"options": {
"polyfills": [] // And from here too
}
}
}
}
}
}
Then uninstall the package:
# terminal
npm uninstall zone.js
Step 3: Migrate NgZone Hooks
If your code uses NgZone.onMicrotaskEmpty, NgZone.onStable, or NgZone.isStable, you need to replace them:
Before:
// app.component.ts - OLD
import { NgZone } from '@angular/core';
export class AppComponent {
constructor(private ngZone: NgZone) {
this.ngZone.onStable.subscribe(() => {
console.log('Angular finished updating');
});
}
}
After:
// app.component.ts - NEW
import { Component, afterEveryRender } from '@angular/core';
export class AppComponent {
constructor() {
afterEveryRender(() => {
console.log('Angular finished updating');
});
}
}
Step 4: SSR and PendingTasks
If you use Server-Side Rendering, Zone.js helped determine when the app was "stable" for serialization. Now you need to use PendingTasks explicitly:
// data.service.ts
import { Injectable, inject } from '@angular/core';
import { PendingTasks } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class DataService {
private pendingTasks = inject(PendingTasks);
async loadData() {
return this.pendingTasks.run(async () => {
const response = await fetch('/api/data');
return response.json();
});
}
}
For observables, use pendingUntilEvent:
// data.service.ts
import { inject } from '@angular/core';
import { PendingTasks } from '@angular/core';
import { pendingUntilEvent } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
export class DataComponent {
private http = inject(HttpClient);
// The app won't serialize until the observable emits
data$ = this.http.get('/api/data').pipe(pendingUntilEvent());
}
Testing Without Zone.js
Testing also changes. Instead of fixture.detectChanges(), use await fixture.whenStable():
// app.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should render correctly', async () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
// Instead of fixture.detectChanges()
await fixture.whenStable();
expect(app.title()).toBe('My App');
});
});
OnPush Is Now the Default Strategy
An important change: Angular v22 renamed ChangeDetectionStrategy.Default to ChangeDetectionStrategy.Eager and set OnPush as the default change detection strategy for new components.
This means when you create a new component:
// new.component.ts
@Component({
selector: 'app-new',
template: `<p>{{ text() }}</p>`,
// changeDetection: ChangeDetectionStrategy.OnPush is now the default
})
export class NewComponent {
text = signal('Hello world');
}
The Reactive Forms Trap
Warning: Reactive Forms don't automatically notify change detection in zoneless mode. If your template depends on reactive forms state, you need to connect it to signals or call markForCheck():
// form.component.ts
import { Component, ChangeDetectorRef, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder } from '@angular/forms';
@Component({
selector: 'app-form',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<input formControlName="name" />
<p>{{ form.get('name')?.value }}</p>
</form>
`,
})
export class FormComponent {
private fb = inject(FormBuilder);
private cdr = inject(ChangeDetectorRef);
form = this.fb.group({ name: [''] });
constructor() {
this.form.valueChanges.subscribe(() => {
this.cdr.markForCheck(); // Required in zoneless
});
}
}
Or better yet, use Signal Forms ;).
Debug Mode: Your Best Ally
Angular offers a tool to verify your app makes notifications correctly:
// main.ts
import { provideCheckNoChangesConfig } from '@angular/core';
bootstrapApplication(App, {
providers: [
provideCheckNoChangesConfig({
exhaustive: true,
interval: 1000, // Checks every second
}),
],
});
If there are bindings that were updated without notification, Angular will throw ExpressionChangedAfterItHasBeenCheckedError.
Conclusion
Zoneless Angular isn't a fad: it's the future (and present) of the framework. Eliminating Zone.js improves performance, simplifies debugging, and ensures long-term compatibility with browser APIs.
If you haven't migrated yet, start today. The path is documented and the migration tools have your back.
For more content on Angular, visit Codea Bien.
Additional references: