For over a decade, Angular has solved a fundamental problem —when to check the UI again after something has changed— with a solution as ingenious as it is invasive:Zone.js, a library that rewrites the browser's asynchronous API at runtime to intercept any possible cause of change. With the arrival ofSignals, Angular is gradually abandoning this approach in favor of a model where the data itself, not a global monkey-patch, knows who needs to be updated. This article explains how Zone.js works internally, why its model has unsolvable structural limitations, and what making an Angular application entails in practicezoneless.
How Zone.js-based change detection works
Zone.js solves a very real problem: Angular needs to knowWhenrecheck components to update the DOM, but JavaScript doesn't natively offer a way to "watch" when a value changes in a generic way. Zone.js' solution is drastic: upon bootstrapping the application,overwrites(monkey-patch) the global asynchronous browser APIs —setTimeout,setInterval,addEventListener,Promise,XMLHttpRequest— so that whenever one of these APIs completes an operation, Zone.js intercepts it and notifies Angular.
The "widespread" change detection cycle
When Zone.js notifies an event, Angular doesn't knowwhat exactlyhe's changed — he just knows thatsomething, somewhere, it may have changed. The answer is to recheck the entire component tree from top to bottom (dirty checking), comparing the values in the templates with those rendered previously:
// Semplificato: cosa succede concettualmente dopo OGNI evento asincrono
zone.onMicrotaskEmpty.subscribe(() => {
applicationRef.tick(); // ricontrolla l'intero albero dei componenti
});
This is why, historically, a singleclickon a button in one corner of the application can cause completely unrelated components elsewhere in the tree to be rechecked —ChangeDetectionStrategy.OnPushmitigates the problem (skips subtrees whose@Input()are not changed by reference), but it doesn't eliminate it: Zone.js still continues to trigger a control loop on every single asynchronous event, whether or not it leads to an actual change.
The structural limits of Zone.js
Zone.js worked well as a "one-size-fits-all" solution, but its approach has problems that can't be solved with incremental optimizations:
1. Bundle and runtime overhead
Zone.js weighs around 30-35KB (minified) in the initial bundle — a fixed cost for each Angular application, regardless of how much it actually uses its functionality. At runtime, monkey-patching each asynchronous API introduces measurable overhead on each individual call, even when it leads to no real change in the UI.
2. Non-selective change detection
Zone.js knowsThatsomething happened, but he doesn't knowWhat. The result is a control loop that in most cases double-checks far more than is necessary — even withOnPushactive everywhere, each asynchronous event still triggers a verification round on the entire tree potentially involved.
3. Fragility with third-party libraries
Global monkey-patching of APIs likePromiseorfetchcan interact unpredictably with libraries that don't expect these APIs to be rewritten at runtime — it's a common and difficult-to-diagnose cause of intermittent bugs in large Angular applications.
4. More complex debugging
Stack traces traverse Zone.js patching code, making it more difficult to trace the real source of an asynchronous error — anyone who has debugged oneUnhandledPromiseRejectionin a large Angular app knows this problem.
What the Signals change
Signals reverses the model: instead of "something happened somewhere, check everything again", the data itself knowsExactlywho depends on him, because the dependency is explicitly recorded at the time of reading:
import { signal, computed, effect } from '@angular/core';
const count = signal(0);
const doubled = computed(() => count() * 2); // dipendenza tracciata automaticamente
effect(() => {
console.log('Il valore doppio è', doubled());
// questo effect si ri-esegue SOLO quando doubled() cambia realmente,
// non ad ogni evento asincrono dell'applicazione
});
count.set(5); // notifica solo i consumer reali: doubled, e l'effect
Whencount.set(5)is called, Angular knows precisely which onescomputed, whicheffectand what template bindings they depend oncount— there is no need to double-check the entire component tree, because the dependency graph is already known in advance.
Zone.js vs Signals: direct comparison
| I wait | Zone.js (classic change detection) | Signals |
|---|---|---|
| How it detects changes | Intercept every global asynchronous API | Explicit tracking of read dependencies |
| What do you double check | The entire tree (or subtree with OnPush) | Only consumers who are actually dependent |
| Bundle overhead | ~30-35KB fixed | Included in the core, no additional costs |
| Compatibility with third party libraries | Risk of conflicts from monkey-patching | No global patching needed |
| Debugging predictability | Stack traces cross patching | Explicit and traceable dependency flow |
| Adoption curve | It works "free" from day one | Requires refactoring of existing state |
What does "Angular zoneless" mean in practice
Angular zoneless doesn't just mean "use Signals in components" — it meansremove Zone.js altogetherfrom the bundle and entrust the entire change detection to the Signals reactivity graph. It is activated with a dedicated provider during the bootstrap phase:
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
// ...altri provider
],
});
Once Zone.js is removed, Angular no longer has any "automatic" way of noticing a generic change —Everything is finestate that must be reflected in the UI must explicitly pass through a Signal (or aChangeDetectorRef.markForCheck()manual in extreme cases). This is why migration is not a simple flag to activate, but a real paradigm shift in state management.
Practical guide: migrate an application to zoneless
1. Check your OnPush coverage
If the application still usesChangeDetectionStrategy.Defaultin many components, it is the first sign that state is not explicitly managed — pass everything toOnPushit's a practical prerequisite before even removing Zone.js.
2. Replace the "implicit" state with Signals
// Prima: proprietà di classe normale, aggiornata via mutazione diretta
export class CartComponent {
itemCount = 0;
addItem(): void {
this.itemCount++; // senza Zone.js, la UI non si aggiornerebbe
}
}
// Dopo: signal, aggiornamento esplicito e tracciato
export class CartComponent {
itemCount = signal(0);
addItem(): void {
this.itemCount.update(n => n + 1); // il template si aggiorna automaticamente
}
}
3. Check third-party libraries
Some libraries (especially older third-party components, or code that implicitly assumes the presence of Zone.js to "wake up" Angular after an event) may stop updating the UI correctly in zoneless mode. They must be tested individually, or wrapped in onemarkForCheck()manual at the integration points.
4. Remove Zone.js from the bundle
Once it has been verified that all state passes through Signals (or explicit manual change detection), Zone.js can be removed from the dependencies and polyfill file, reducing the initial bundle.
When it is NOT yet convenient to go zoneless
- Applications with many outdated third-party dependencies: If critical libraries assume the presence of Zone.js, migration requires extensive case-by-case testing.
- Very large codebases with unevenly managed state: if the state is scattered between directly mutated class properties, services with RxJS not integrated with Signals, and legacy logic, the necessary refactoring is substantial.
- Teams unfamiliar with Signals: zoneless amplifies any gaps in explicit state management — it's worth solidifying your Signals knowledge first with Zone.js still active as a safety net.
Frequently asked questions
Do I need to remove Zone.js to use Signals?
No, Signals work perfectly even with Zone.js still active — it's the most common intermediate step: first adopt Signals for state, then (optionally) remove Zone.js when coverage is complete.
Do Signals replace RxJS in Angular?
No, they cover different use cases: Signals are designed for synchronous state local to components, RxJS remains the right tool for complex asynchronous flows (HTTP requests, WebSockets, combination of multiple events over time). The utilitiestoSignal()AndtoObservable()allow the two models to coexist.
Is Zoneless ready for production yet?
Zoneless support is stable in recent versions of Angular, but requires that the entire application (including third-party libraries in use) is compatible with an explicit change detection model — it must be evaluated on a case-by-case basis, it is not a simple universal switch.
What happens if I forget to use a Signal for a state that needs to update the UI?
In zoneless mode, the UI simply doesn't update until another change detection trigger occurs (e.g. a template event) — it's a silent bug, which is why migration requires extensive testing on each flow.
Do Signals improve performance even without going zoneless?
Yes: even with Zone.js still active, Signals-based bindings in templates allow Angular to update only the actually dependent DOM nodes, reducing rendering work compared to classic bindings.
Do I need to rewrite the entire application to adopt Signals?
No, Signals and "classic" state can coexist in the same component and application — you can migrate incrementally, component by component.
In summary
Zone.js has allowed Angular to offer automatic change detection "for free" since day one, but the price is a model that double-checks much more than it needs, with a fixed bundle cost and a known fragility in integration with third-party libraries. Signals solves the problem at its root, tracking dependencies explicitly instead of intercepting every asynchronous browser event — and paves the way for a zoneless Angular that is lighter and more predictable to debug. If you're considering when to use Signals versus RxJS in day-to-day state management, this guide links directly toAngular Signals vs RxJS: when to use one and when the other.