#angular#webdev#performance#typescript

Why We Dropped Zone.js in Angular 21 (And What It Actually Changed)

We ripped Zone.js out of our production Angular apps and measured the difference. Here's what zoneless change detection with Signals actually gets you, with real numbers.

Habibur Rahman
Habibur Rahman@devhabib
Aug 16 5 min read
Why We Dropped Zone.js in Angular 21 (And What It Actually Changed)

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.js and 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
  ]
};
Enter fullscreen mode Exit fullscreen mode

Then pull zone.js out for real:

  1. Remove it from polyfills in angular.json (both build and test targets):
"polyfills": [
  "@angular/localize/init"
]
Enter fullscreen mode Exit fullscreen mode
  1. 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' }))
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
  );
}
Enter fullscreen mode Exit fullscreen mode

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