Angular Zoneless: What Changes Now That Zone.js Is Gone [2026]

Link copied
Angular Zoneless: What Changes Now That Zone.js Is Gone [2026]

Angular Zoneless: What Changes Now That Zone.js Is Gone [2026]

Angular Tutorial Module 3: Signals Lesson 3.5

Angular shipped its largest single architectural change in years quietly: as of v21 (November 2025), new projects are zoneless by default. The Zone.js library — which monkey-patched every browser API to drive change detection for a decade — is gone from new scaffolds. Bundles shrank, performance went up, and the framework moved one step closer to fully signal-driven reactivity.

This is lesson 3.5 of the Angular Tutorial. It explains what changed at the user-visible level. Lesson 3.6 covers what triggers change detection now (the internals). Together they answer "why does my view not update?" — the most common 2026 Angular debugging question.

What Zone.js used to do #

For years, Angular relied on Zone.js to detect when the application's state might have changed. Zone.js patched every browser API that could trigger asynchronous work — setTimeout, setInterval, Promise resolution, every DOM event, XHR completions, RxJS schedulers, you name it. After each patched callback returned, Angular ran change detection on the entire component tree.

The model was reliable but expensive. Every click, every timer, every fetch triggered a full tree walk regardless of whether anything actually changed. Optimizations like OnPush mitigated the cost; zoneless removes the cost entirely.

What zoneless does instead #

Zoneless change detection is explicitly signal-driven. Angular only checks a component when:

  1. A signal it reads changed — the reactivity graph (lesson 3.1) notifies the framework
  2. An event handler in its template fired(click), (input), etc., automatically schedule CD on that component
  3. A child's output() emitted — same as event handlers
  4. ApplicationRef.tick() was called — manual escape hatch for non-signal code paths
  5. An async pipe emittedasync pipe wraps RxJS Observables; it triggers CD when it gets a new value

Nothing else triggers change detection. setTimeout from a third-party library doesn't. A fetch resolution doesn't. A WebSocket message doesn't. Only the five sources above.

This is what makes zoneless faster: in a typical app, 90% of "async events" the old Zone.js model intercepted didn't change anything render-relevant. Skipping CD for those wins back the time.

What you have to change in YOUR code #

For a brand-new Angular 21+ app: nothing. The defaults Just Work. The interesting question is what happens to existing code patterns.

Component state as signals — fine #

export class CounterComponent {
  count = signal(0);
  inc() { this.count.update(n => n + 1); }
}

Updating a signal automatically schedules CD on every component that reads it. No code changes needed.

Component state as plain fields — works for events, breaks otherwise #

export class CounterComponent {
  count = 0;
  inc() { this.count++; }   // works — the (click) handler triggers CD
}

This still works! Event handlers in the template auto-schedule CD on the host component. The view re-renders even though count is not a signal.

Where it breaks: updates that happen OUTSIDE a template event handler.

export class ClockComponent {
  time = new Date().toLocaleTimeString();

  constructor() {
    setInterval(() => {
      this.time = new Date().toLocaleTimeString();   // ❌ no CD trigger in zoneless
    }, 1000);
  }
}

In zone-based Angular, the patched setInterval would trigger CD. In zoneless, it does NOT — so the field updates in memory but the template never re-renders. Fix: make time a signal.

RxJS observables in the template — fine #

The async pipe wraps each emission in a CD trigger:

<p>{{ messages$ | async }}</p>     <!-- re-renders on each emission, zoneless or not -->

No change needed.

HTTP calls — fine #

HttpClient returns Observables. Whether you read them via async pipe, toSignal(), or resource(), they all trigger CD on the consumer.

this.http.get<User>('/api/user').subscribe(user => {
  this.user.set(user);                  // signal — CD triggers
  this.user = user;                      // ❌ plain field — NO CD trigger
});

The pattern is the same: assign to a signal, you're fine; mutate a plain field, you're not.

What breaks in legacy code #

The places to audit in an existing codebase before migrating to zoneless:

Pattern Zone.js Zoneless
(click)="foo = 1" in template Works Works (event triggers CD)
setTimeout(() => this.foo = 1) Works ❌ Field updated, view doesn't update
fetch(...).then(r => this.data = r) Works ❌ Same
addEventListener('resize', ...) callback updates state Works ❌ Same
setInterval(() => tick++) for a clock Works ❌ Same
WebSocket onmessage updating state Works ❌ Same
setTimeout writing to a signal Works ✓ Signal write triggers CD

The fix for every red row: make the state a signal. this.foo = 1 becomes this.foo.set(1). The view re-renders.

For cases where you cannot easily switch to signals (third-party callbacks that mutate component state), the escape hatch is inject(ApplicationRef).tick() — a manual CD trigger. Use sparingly; if you find yourself needing it often, the underlying state should be a signal.

Migration: opt in, then opt out of Zone.js #

For existing apps moving to zoneless, the recommended path:

  1. Add provideZonelessChangeDetection() to appConfig.providers. This activates zoneless mode.
  2. Run your test suite. Failing tests usually point at exactly the patterns in the table above.
  3. Convert flagged state to signals. signal(initial) + .set() / .update().
  4. Remove zone.js from polyfills. Drop it from package.json and angular.json once zoneless is stable.

The Angular team recommends running both Zone.js AND zoneless in parallel during migration to compare behavior. Once you are confident, drop Zone.js.

The onpush_zoneless_migration MCP tool in the Angular CLI's MCP server (lesson 12.1) audits your codebase and produces a step-by-step plan.

Performance gains #

Numbers from the Angular team's benchmarks (typical mid-sized app):

Metric Zone.js Zoneless Delta
Bundle size (gzip) 36 KB 24 KB −33%
Initial change detection 12ms 4ms −67%
Per-event CD overhead 3-8ms 0.5-2ms −75%
Long-task count (per minute, scrolling) 22 6 −73%

Your mileage will vary. The headline: a typical zoneless app feels noticeably more responsive than the same app on Zone.js, especially during scroll, drag, and rapid-event sequences.

When you still need Zone.js #

Three cases:

  1. Third-party libraries that expect zones. Some Angular Material modules pre-v17 do. Modern Material is zoneless-compatible.
  2. Legacy protractor E2E tests. They rely on NgZone.isStable() to know when to act. Migrate to Playwright/Cypress (lesson 11.5).
  3. Heavy RxJS code without async pipes. If you .subscribe() in code and mutate component fields (not signals), Zone.js was hiding the bug. Switch to signals or async pipe.

For brand-new code in 2026, none of these apply.

Verifying you're zoneless #

In your app's runtime:

import { NgZone, inject } from '@angular/core';

const zone = inject(NgZone);
console.log('NgZone is:', zone.constructor.name);
// → 'NoopNgZone' (zoneless)
// → 'NgZone' (zone-based)

Or check window.Zone in the browser console:

typeof Zone   // 'undefined' (zoneless) or 'object' (zone-based)

If you scaffolded a new project with the CLI in v21+, you're zoneless by default.

Common gotchas #

Symptom Cause Fix
Clock component stops updating in zoneless time = 'X' (plain field) updated by setInterval Make time a signal; setInterval(() => this.time.set(new Date().toString()))
Form input value doesn't update on paste event (paste) handler missing — only (input) triggers CD by default Add the missing event binding
Third-party RxJS subscription doesn't trigger render .subscribe(v => this.foo = v) writes to a plain field Use toSignal(observable) instead, or write to a signal
setTimeout callback updates view in tests but not production Tests may run in Zone.js mode while production is zoneless Make sure both use the same CD strategy; switch to signals for portability
Console warning about NoopNgZone Some library is calling NgZone.run(fn) expecting zone behavior The library is not zoneless-compatible yet; either upgrade it or wrap the code with inject(ApplicationRef).tick()

What's next #

Lesson 3.6 dives INTO the zoneless change-detection algorithm — exactly what triggers a CD pass, how dirty marking works, what markForCheck() does now. Lesson 3.7 covers the signal-vs-observable decision tree. Lesson 3.8 closes Module 3 with linkedSignal().

Try it yourself #

A simple test for whether your app is zoneless:

import { Component, inject, NgZone } from '@angular/core';

@Component({
  selector: 'app-zone-check',
  template: `<p>NgZone is: {{ zoneName }}</p>`,
})
export class ZoneCheckComponent {
  zoneName = inject(NgZone).constructor.name;
}

Drop into any new component, render. If it says NoopNgZone, you're zoneless. If it says NgZone, you still have Zone.js. The migration target is NoopNgZone.

YouI have an Angular 17 app. Should I migrate to zoneless now, or wait?
Claude · used onpush_zoneless_migrationIf your app is mostly signal-based already, migrate now — the gains are real and the migration is mostly mechanical. If your code still mutates component fields from setTimeout/fetch/RxJS without signals, do the signal migration FIRST (use the Angular CLI’s signal-input/signal-query/signal-output migrations), then flip the zoneless switch. Mixing the two migrations at once makes it harder to tell which change broke what. Either way, the path is forward — Zone.js is on its way out.

Lesson 3.6 picks up the CD internals — what makes Angular decide to re-render in zoneless mode.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *