Zone.js has been running the show in Angular since 2016. It patches almost every async API in the browser — setTimeout, Promise, XHR, DOM events, all of it — so that Angular knows the second something might have changed and can go check.
It worked. It also came with baggage:
- Every async microtask kicks off a full traversal of the component tree, whether anything actually changed or not
- Stack traces get mangled because you're debugging through monkey-patched wrappers
- You're shipping the weight of
zone.jsand locking up the main thread during anything data-heavy
With Angular 21, you can finally just... not do that. Native zoneless change detection powered by Signals is here, and we moved our core architecture over to it a few months ago. Here's what changed, what broke, and what the numbers actually looked like once we measured instead of guessing.
What Zone.js Was Actually Costing Us
Picture a real-time payroll table or a dashboard with a WebSocket feed pushing updates every second. Here's roughly what happens on every single event, even with OnPush everywhere:
User Event / WebSocket Message
│
▼
Zone.js Intercepts Async Call
│
▼
Triggers ApplicationRef.tick()
│
▼
Traverses Component Tree (Top to Bottom)
│
▼
Checks Dirty State Across Dozens/Hundreds of Components
OnPush helps, but Angular still has to walk root-to-leaf checking whether an input reference changed. On a low-end phone, that constant scheduling on the main thread is exactly what tanks Total Blocking Time and Interaction to Next Paint. We'd optimized components, added trackBy everywhere, split modules — and still watched INP creep up on mid-range Android devices under load.
The Zoneless Model, In Practice
Without Zone.js, Angular stops listening for "something happened somewhere" and instead reacts to a dependency graph:
Signal Value Updates (set / update)
│
▼
Marks the Specific Consumer Component as Dirty
│
▼
Schedules a Targeted Microtask Render for That View
No global patching, no top-down scan. A signal changes, the component that actually reads it re-renders, done.
Step 1: Turning It On
The bootstrap side is short. In app.config.ts:
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideRouter(appRoutes)
]
};
Then pull zone.js out for real:
- Remove it from
polyfillsinangular.json(bothbuildandtesttargets):
"polyfills": [
"@angular/localize/init"
]
- Uninstall it from
package.json.
That part took us ten minutes. The actual work was step 2.
Step 2: The Part That Takes Longer — Fixing Your Reactive Patterns
Turning the flag on doesn't make your app zoneless-safe by itself. Anything reading or mutating state outside the signal graph just silently stops updating the view, and there's no error for it. You find out from a QA ticket that says "the button doesn't do anything," and then you spend an hour figuring out which service is still mutating a plain array in place.
Signals for state you own
Skip mutable class fields. Wrap state in signals and computeds so Angular actually knows when it changed:
import { Component, computed, signal } from '@angular/core';
interface Transaction {
id: string;
amount: number;
status: 'PENDING' | 'SETTLED';
}
@Component({
selector: 'app-payroll-summary',
standalone: true,
template: `
<section class="payroll-card">
<h3>Total Processed: {{ totalAmount() | currency }}</h3>
<p>Active Records: {{ transactionCount() }}</p>
<button (click)="settleAll()">Settle Pending</button>
</section>
`
})
export class PayrollSummaryComponent {
transactions = signal<Transaction[]>([
{ id: 'tx-1', amount: 1500, status: 'PENDING' },
{ id: 'tx-2', amount: 2400, status: 'SETTLED' }
]);
totalAmount = computed(() =>
this.transactions().reduce((sum, item) => sum + item.amount, 0)
);
transactionCount = computed(() => this.transactions().length);
settleAll(): void {
this.transactions.update(items =>
items.map(tx => ({ ...tx, status: 'SETTLED' }))
);
}
}
Bridging RxJS with toSignal
You're not throwing out RxJS — HTTP calls, WebSockets, and route params still make sense as streams. Just close the loop back into the signal graph with toSignal instead of subscribing manually and pushing into a field:
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-analytics-feed',
standalone: true,
template: `
@if (metrics(); as data) {
<div class="metrics-grid">
<span>Active Users: {{ data.activeUsers }}</span>
<span>Throughput: {{ data.requestsPerSec }} req/s</span>
</div>
} @else {
<p>Streaming real-time metrics...</p>
}
`
})
export class AnalyticsFeedComponent {
private readonly http = inject(HttpClient);
metrics = toSignal(
this.http.get<{ activeUsers: number; requestsPerSec: number }>('/api/v1/metrics'),
{ initialValue: null }
);
}
Do this consistently and manual subscriptions that forget to notify Angular basically stop being a category of bug.
What We Measured
We ran this on Moto G4 emulation profiles against a payroll-heavy internal dashboard, before and after the migration:
| Metric | With Zone.js | Zoneless Angular 21 | Change |
|---|---|---|---|
| Initial JS bundle size | 184 KB | 148 KB | -19.5% |
| Total Blocking Time | 480 ms | 35 ms | -92.7% |
| Interaction to Next Paint | 160 ms | 18 ms | -88.7% |
| Change detection cycles/sec | continuous | on-demand only | ~90% less CPU |
The INP drop was the one that mattered most for us — it's the metric our client dashboards were actually failing Core Web Vitals on, and it's the one users feel directly when they tap something and wait for it to respond.
Worth being honest here: these numbers are from one internal app on one device profile, not a controlled study across dozens of apps. Your mileage depends heavily on how disciplined your codebase already is about OnPush and immutable state. If you're already leaning on Signals and NgRx SignalStore, the jump is smaller than what we saw. If you've got a codebase full of direct array mutation and manual subscriptions, expect the migration itself to take longer than the flag flip suggests.
Where This Leaves Us
Zone.js isn't broken, and it's not going anywhere overnight for teams with large legacy codebases — this is still marked experimental. But if you're starting fresh, or you've already got a reasonably disciplined signal-based architecture, there's not much reason to keep shipping the patching overhead. The change detection model finally matches how the rest of the reactive frontend world thinks about state: something changes, the thing that depends on it updates, nothing else moves.
We're rolling this out across our other client projects at Devynelogic gradually rather than all at once — partly because "experimental" in the Angular docs means exactly what it says, and partly because finding every stray mutation in an older codebase is tedious work best done in small batches.
If you're mid-migration or hitting silent update failures after flipping the flag, I'd genuinely like to hear what broke for you — drop it in the comments.


💬 Discussion & Comments (0)
Join the Conversation on Dev.to
Comments and reactions for this article are hosted on Dev.to. Share your thoughts, ask questions, or join the discussion directly on the official post.
No comments on Dev.to yet. Be the first to start the discussion!
Comment on Dev.to