Zoneless Change Detection: El fin de Zone.js en Angular
Angular ha iniciado una transformación revolucionaria que elimina Zone.js, reduciendo bundles en 33KB y mejorando el rendimiento hasta un 60%. Este cambio representa la evolución más significativa en la arquitectura de detección de cambios desde Angular 2, marcando el fin de una era basada en “magia automática” y el inicio de un enfoque explícito y reactivo impulsado por Signals. Desde Angular 20.2 (febrero 2025), zoneless change detection pasó de experimental a estable, y a partir de Angular 21 será el modo por defecto en todos los proyectos nuevos. Esta transición no solo mejora métricas de rendimiento críticas como First Contentful Paint (40-60% más rápido) y Largest Contentful Paint (30-50% de mejora), sino que también simplifica el debugging, elimina ciclos innecesarios de detección de cambios, y abre las puertas a una mejor compatibilidad con el ecosistema JavaScript moderno. Para desarrolladores de Angular, entender esta transformación ya no es opcional: es fundamental para construir aplicaciones competitivas en 2025 y más allá.
Entendiendo Zone.js: la piedra angular que Angular está dejando atrás
Zone.js fue introducido en Angular 2 (2016) como una biblioteca que proporciona un contexto de ejecución persistente a través de operaciones asíncronas. Inspirado en el concepto de Zones de Dart, Zone.js funciona mediante una técnica llamada monkey patching: intercepta y modifica las APIs nativas del navegador en tiempo de ejecución para rastrear todas las operaciones asíncronas automáticamente.
Cuando una aplicación Angular arranca con Zone.js, este parchea funciones críticas del navegador: setTimeout, setInterval, Promise, XMLHttpRequest, fetch, y todos los event listeners del DOM. Cada vez que ocurre cualquier evento asíncrono (un click, una respuesta HTTP, un timer), Zone.js lo detecta y notifica a Angular a través del servicio NgZone. Esta notificación dispara ApplicationRef.tick(), que ejecuta un recorrido completo del árbol de componentes para verificar cambios y actualizar el DOM.
El problema fundamental es que Zone.js no puede proporcionar información sobre QUÉ cambió o DÓNDE ocurrió el cambio. Solo sabe que “algo asíncrono terminó”. Como resultado, Angular debe verificar potencialmente todo el árbol de componentes en cada operación asíncrona, incluso cuando nada cambió realmente. Imagina un setInterval ejecutándose cada segundo: dispara detección de cambios completa 60 veces por minuto, sin importar si la aplicación necesita actualizarse o no.
Este enfoque tiene costos medibles: Zone.js añade aproximadamente 33KB al bundle de producción (15% de una aplicación Angular básica), introduce overhead de rendimiento por el patching de APIs, complica las stack traces con múltiples capas de ZoneDelegate.invoke, y crea incompatibilidades con código moderno como async/await (que debe ser “downleveled” a generators para funcionar con Zone.js). Además, genera el notorio problema de “zone pollution”: librerías de terceros que ejecutan tareas que no modifican datos de la aplicación disparan innecesariamente detección de cambios completa.
La arquitectura interna de zoneless: cómo funciona sin magia
Zoneless change detection invierte completamente el modelo de Zone.js: en lugar de que el framework detecte automáticamente todas las operaciones asíncronas, los componentes notifican explícitamente a Angular cuando los datos cambian. Esta transformación es posible gracias a tres innovaciones arquitectónicas fundamentales introducidas entre Angular 16 y 18.
Primero, el sistema de Signals (introducido en Angular 16) proporciona primitivas reactivas de grano fino. Un Signal es un contenedor reactivo que notifica automáticamente a sus consumidores cuando su valor cambia. Cuando un Signal se lee en un template, Angular registra ese componente como “consumidor reactivo” del Signal. Si el Signal se actualiza con set() o update(), solo ese consumidor específico se marca como “dirty”, en lugar de marcar todo el árbol de componentes.
Segundo, el ChangeDetectionSchedulerImpl reemplaza el scheduler basado en NgZone. Este scheduler implementa un método notify() que se invoca cuando ocurren cambios que requieren actualización de la vista: actualizaciones de Signals leídos en templates, llamadas a markForCheck(), valores recibidos por el AsyncPipe, eventos de template como (click), o cambios de inputs dinámicos con ComponentRef.setInput(). Cuando se invoca notify(), el scheduler ejecuta la función scheduleCallback(), que crea una condición de carrera entre setTimeout y requestAnimationFrame: el que termine primero gana y ejecuta ApplicationRef.tick(). Este mecanismo coalesa múltiples notificaciones en un solo ciclo de detección de cambios, optimizando el rendimiento.
Tercero, Angular 17 introdujo detección de cambios “local” o “glocal” (global-local) mediante la función markAncestorsForTraversal. A diferencia del antiguo markViewDirty que marcaba el componente actual y todos sus ancestros como “dirty”, markAncestorsForTraversal solo marca el consumidor reactivo específico como dirty, mientras que los ancestros reciben simplemente un flag HasChildViewsToRefresh. Durante el ciclo de detección, Angular comienza desde la raíz, pero cuando encuentra componentes con OnPush que solo tienen el flag HasChildViewsToRefresh, los traversa pero no los verifica. Solo cuando llega al componente específico marcado como dirty, ejecuta la verificación real. El resultado: en lugar de verificar 10 componentes en una ruta, Angular verifica solo 1, el que realmente cambió.
// Implementación interna simplificada del scheduler zoneless
export function scheduleCallback(callback: Function): () => void {
let executeCallback = true;
// Carrera entre setTimeout y requestAnimationFrame
nativeSetTimeout(() => {
if (!executeCallback) return;
executeCallback = false;
callback(); // Ejecuta ApplicationRef.tick()
});
nativeRequestAnimationFrame?.(() => {
if (!executeCallback) return;
executeCallback = false;
callback(); // Ejecuta ApplicationRef.tick()
});
return () => { executeCallback = false; }; // Función de cancelación
}
La configuración de zoneless se realiza mediante el provider provideZonelessChangeDetection() (estable desde Angular 20.2) o provideExperimentalZonelessChangeDetection() (Angular 18-19). Este provider configura tres elementos clave: registra el ChangeDetectionSchedulerImpl como el scheduler activo, proporciona una implementación NoopNgZone (NgZone vacío que no hace nada), y establece el flag ZONELESS_ENABLED en true. Una vez configurado, debes eliminar Zone.js completamente del proyecto: removerlo del array de polyfills en angular.json, eliminar imports en archivos de polyfills, y desinstalarlo con npm uninstall zone.js.
Implementando zoneless: guía práctica paso a paso
La implementación de zoneless change detection varía significativamente dependiendo de si estás comenzando un proyecto nuevo o migrando una aplicación existente. Para proyectos nuevos en Angular 21 o posterior, el proceso es trivial: el CLI pregunta automáticamente durante ng new si deseas habilitar zoneless, y configurará todo correctamente. Para Angular 18-20, o para migrar aplicaciones existentes, el proceso requiere varios pasos cuidadosos.
Configuración inicial para aplicaciones nuevas o en migración:
Primero, actualiza a Angular 20.2 o superior para acceder a las APIs estables. Si estás en Angular 18-19, puedes usar las APIs experimentales pero con menos garantías de estabilidad. En tu archivo app.config.ts, agrega el provider de zoneless:
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(), // Angular 20.2+
// o provideExperimentalZonelessChangeDetection() para Angular 18-19
provideRouter(routes),
provideHttpClient(),
]
};
A continuación, elimina Zone.js completamente del proyecto. En angular.json, localiza la sección de polyfills tanto en build como en test y remueve todas las referencias a "zone.js" y "zone.js/testing". Si tu proyecto tiene un archivo polyfills.ts, elimina cualquier línea que importe zone.js. Finalmente, desinstala el package: npm uninstall zone.js. Puedes verificar que la configuración fue exitosa abriendo la consola del navegador y escribiendo Zone: deberías recibir el error Uncaught ReferenceError: Zone is not defined.
Patrones de código para componentes zoneless:
El patrón más recomendado es usar Signals para todo el estado reactivo del componente. Los Signals son nativamente compatibles con zoneless y notifican automáticamente los cambios:
@Component({
selector: 'app-user-profile',
changeDetection: ChangeDetectionStrategy.OnPush, // Altamente recomendado
template: `
<h2>{{ user().name }}</h2>
<p>Email: {{ user().email }}</p>
<p>Posts totales: {{ postsCount() }}</p>
<button (click)="loadPosts()">Cargar posts</button>
@if(isLoading()) {
<div class="spinner">Cargando...</div>
}
@for(post of posts(); track post.id) {
<article>
<h3>{{ post.title }}</h3>
<p>{{ post.content }}</p>
</article>
}
`
})
export class UserProfileComponent {
// Signals para estado reactivo
user = signal<User>({ name: 'Juan', email: 'juan@ejemplo.com' });
posts = signal<Post[]>([]);
isLoading = signal(false);
// Computed signals para valores derivados
postsCount = computed(() => this.posts().length);
http = inject(HttpClient);
loadPosts() {
this.isLoading.set(true);
this.http.get<Post[]>('/api/posts').subscribe({
next: (data) => {
this.posts.set(data); // Signal actualiza automáticamente la vista
this.isLoading.set(false);
},
error: () => this.isLoading.set(false)
});
}
}
Para operaciones HTTP, tienes tres estrategias efectivas. La primera y más moderna es convertir Observables a Signals usando toSignal():
@Component({
template: `
@if(users(); as userList) {
@for(user of userList; track user.id) {
<div>{{ user.name }}</div>
}
}
`
})
export class UsersComponent {
http = inject(HttpClient);
// Observable convertido a Signal automáticamente
users = toSignal(
this.http.get<User[]>('/api/users'),
{ initialValue: [] as User[] }
);
}
La segunda estrategia es usar el AsyncPipe, que permanece completamente compatible con zoneless porque internamente llama a markForCheck():
@Component({
template: `
@if(users$ | async; as userList) {
@for(user of userList; track user.id) {
<div>{{ user.name }}</div>
}
}
`
})
export class UsersComponent {
http = inject(HttpClient);
users$ = this.http.get<User[]>('/api/users');
}
La tercera estrategia, solo cuando las anteriores no son viables, es notificar manualmente con ChangeDetectorRef.markForCheck():
@Component({
template: `<div>Contador: {{ tick }}</div>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimerComponent implements OnInit, OnDestroy {
tick = 0;
private intervalId?: number;
cdr = inject(ChangeDetectorRef);
ngOnInit() {
this.intervalId = window.setInterval(() => {
this.tick++;
this.cdr.markForCheck(); // Notificación manual requerida
}, 1000);
}
ngOnDestroy() {
if (this.intervalId) clearInterval(this.intervalId);
}
}
Sin embargo, la mejor solución para timers es usar Signals, que eliminan la necesidad de notificaciones manuales:
@Component({
template: `<div>Contador: {{ tick() }}</div>`
})
export class TimerComponent implements OnInit, OnDestroy {
tick = signal(0);
private intervalId?: number;
ngOnInit() {
this.intervalId = window.setInterval(() => {
this.tick.update(t => t + 1); // Signal notifica automáticamente
}, 1000);
}
ngOnDestroy() {
if (this.intervalId) clearInterval(this.intervalId);
}
}
Estrategia OnPush: el compañero esencial de zoneless
Aunque no es estrictamente obligatorio, usar ChangeDetectionStrategy.OnPush en todos los componentes es altamente recomendado para aplicaciones zoneless. OnPush indica a Angular que un componente solo debe verificarse cuando sus inputs cambian, cuando se llama markForCheck() explícitamente, o cuando un Signal leído en el template se actualiza. Esta estrategia complementa perfectamente zoneless al eliminar verificaciones innecesarias y hacer que el flujo de datos sea más predecible.
@Component({
selector: 'app-product-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<h3>{{ product().name }}</h3>
<p>{{ product().price | currency }}</p>
<button (click)="addToCart()">Agregar al carrito</button>
</div>
`
})
export class ProductCardComponent {
product = input.required<Product>(); // Signal input (Angular 17.1+)
cartService = inject(CartService);
addToCart() {
this.cartService.add(this.product());
// El evento click dispara change detection automáticamente
}
}
Migrando aplicaciones existentes: estrategias y desafíos comunes
La migración de una aplicación Angular existente a zoneless requiere un enfoque sistemático. El primer paso crítico es auditar el uso de APIs de NgZone incompatibles. Busca en tu código usos de NgZone.onMicrotaskEmpty, NgZone.onStable, y NgZone.onUnstable: estos observables nunca emiten en zoneless y deben ser reemplazados. La alternativa son las funciones afterNextRender() (para esperar un ciclo de renderizado) o afterRender() (para ejecutar código después de cada renderizado):
// ❌ Código incompatible con zoneless
export class OldComponent {
constructor(private ngZone: NgZone) {
this.ngZone.onStable.subscribe(() => {
console.log('Angular completó detección de cambios');
this.performDOMOperation();
});
}
}
// ✅ Código compatible con zoneless
export class NewComponent {
constructor() {
afterNextRender(() => {
console.log('Renderizado completado');
this.performDOMOperation();
});
}
}
El segundo desafío común es código que asume detección automática de cambios. Esto es particularmente problemático con setTimeout, setInterval, y subscripciones manuales a Observables:
// ❌ Patrón problemático: subscripción manual sin notificación
@Component({
template: `
@for(item of items; track item.id) {
<div>{{ item.name }}</div>
}
`
})
export class ItemsComponent implements OnInit {
items: Item[] = [];
ngOnInit() {
this.dataService.getItems().subscribe(
data => this.items = data // Vista no se actualiza en zoneless
);
}
}
// ✅ Solución 1: Usar toSignal
export class ItemsComponent {
dataService = inject(DataService);
items = toSignal(this.dataService.getItems(), { initialValue: [] });
}
// ✅ Solución 2: Usar AsyncPipe
export class ItemsComponent {
dataService = inject(DataService);
items$ = this.dataService.getItems();
// Template: @for(item of items$ | async; track item.id)
}
// ✅ Solución 3: Notificación manual (último recurso)
export class ItemsComponent implements OnInit {
items: Item[] = [];
cdr = inject(ChangeDetectorRef);
ngOnInit() {
this.dataService.getItems().subscribe(data => {
this.items = data;
this.cdr.markForCheck(); // Notificar manualmente
});
}
}
El tercer obstáculo son librerías de terceros que dependen de Zone.js. Angular Material y CDK son completamente compatibles desde la versión 18, pero otras librerías pueden tener problemas. Para código legacy de librerías que no se actualizan, envuelve sus callbacks con notificaciones explícitas:
export class ChartComponent implements OnInit {
chartData = signal<ChartData | null>(null);
ngOnInit() {
// Librería externa que ejecuta callback cuando los datos cambian
externalChartLibrary.onDataUpdate((newData: ChartData) => {
this.chartData.set(newData); // Signal notifica automáticamente
});
}
}
Testing en zoneless: adaptando tu suite de pruebas
El testing de aplicaciones zoneless requiere ajustes en la configuración de TestBed y en los patrones de testing. Lo más importante es preferir fixture.whenStable() sobre fixture.detectChanges(), ya que el primero respeta el flujo natural de change detection:
describe('UserComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserComponent],
providers: [
provideZonelessChangeDetection(),
provideHttpClient() // Para HttpClientTestingModule
]
}).compileComponents();
});
it('debe actualizar la vista cuando cambia el signal', async () => {
const fixture = TestBed.createComponent(UserComponent);
await fixture.whenStable(); // Esperar estabilidad inicial
const component = fixture.componentInstance;
component.userName.set('María');
await fixture.whenStable(); // Esperar actualización
const element = fixture.nativeElement.querySelector('h2');
expect(element.textContent).toContain('María');
});
it('debe manejar requests HTTP correctamente', async () => {
const httpTesting = TestBed.inject(HttpTestingController);
const fixture = TestBed.createComponent(UserComponent);
await fixture.whenStable();
component.loadUsers();
const req = httpTesting.expectOne('/api/users');
req.flush([{ id: 1, name: 'Test' }]);
await fixture.whenStable();
expect(component.users()).toEqual([{ id: 1, name: 'Test' }]);
});
});
Además, las funciones fakeAsync y tick de Angular no funcionan correctamente en zoneless. En su lugar, usa los mock clocks de Jasmine o Jest:
// ❌ No funciona en zoneless
it('debe actualizar después de delay', fakeAsync(() => {
component.startTimer();
tick(1000);
expect(component.count).toBe(1);
}));
// ✅ Funciona con zoneless
it('debe actualizar después de delay', () => {
jasmine.clock().install();
component.startTimer();
jasmine.clock().tick(1000);
expect(component.count()).toBe(1);
jasmine.clock().uninstall();
});
Beneficios medibles: rendimiento, bundles y experiencia de desarrollo
Los beneficios de zoneless change detection se manifiestan en tres dimensiones fundamentales: rendimiento en runtime, tamaño de bundle, y experiencia de desarrollo.
Rendimiento en runtime: Al eliminar Zone.js, las aplicaciones evitan el overhead de monkey patching de todas las APIs asíncronas del navegador. Más importante aún, zoneless elimina ciclos innecesarios de detección de cambios. Con Zone.js, cada operación asíncrona dispara change detection completo, incluso cuando nada cambió. En zoneless, los cambios solo ocurren cuando hay notificaciones explícitas de Signals, AsyncPipe, o markForCheck. En aplicaciones de prueba con 30+ componentes, se observan mejoras del 2-5% en rendimiento runtime y 10-15% en startup time. Aplicaciones más grandes reportan mejoras aún mayores.
Las Core Web Vitals muestran los impactos más dramáticos. First Contentful Paint (FCP) mejora entre 40-60% porque la aplicación carga y ejecuta menos código al inicio. Largest Contentful Paint (LCP) mejora entre 30-50% gracias a menos procesamiento durante la carga inicial. Google Fonts reportó mejoras consistentes del 45% en LCP después de migrar a zoneless. Cumulative Layout Shift (CLS) también mejora porque los cambios son más predecibles y menos propensos a causar reflows inesperados.
Reducción de tamaño de bundle: Zone.js contribuye aproximadamente 33KB raw (10KB gzipped) al bundle de una aplicación Angular típica. Para una aplicación básica de 216KB, esto representa el 15% del tamaño total. Eliminarlo resulta en bundles 13KB más pequeños después de minificación y tree-shaking. Adicionalmente, sin Zone.js, no hay necesidad de downleveling de async/await a generators, lo que reduce aún más el código generado. En aplicaciones que usan extensively async/await, las reducciones pueden alcanzar 15-20KB adicionales.
Experiencia de desarrollo mejorada: Las stack traces sin Zone.js son dramáticamente más legibles. Con Zone.js, cada operación asíncrona inyecta múltiples capas de ZoneDelegate.invoke, Zone.run, NgZone.runOutsideAngular que obscurecen el verdadero origen de errores. Sin Zone.js, las stack traces muestran directamente la secuencia de llamadas de tu código. El debugging se vuelve más intuitivo porque el flujo de datos es explícito: puedes rastrear exactamente qué Signal cambió y qué componente se notificó. El temido error “ExpressionChangedAfterItHasBeenCheckedError” se vuelve más fácil de diagnosticar porque los cambios son explícitos, no resultado de side effects ocultos en callbacks asíncronos.
Detalles avanzados: SSR, PendingTasks, y el futuro de Angular
Server-Side Rendering con zoneless requiere manejo especial porque el servidor necesita saber cuándo la aplicación completó todo el trabajo asíncrono antes de serializar el HTML. Con Zone.js, esto era automático: Zone.js notificaba cuando no había más tareas pendientes. Sin Zone.js, debes usar el servicio PendingTasks para indicar trabajo asíncrono:
@Component({
selector: 'app-product-detail'
})
export class ProductDetailComponent implements OnInit {
pendingTasks = inject(PendingTasks);
http = inject(HttpClient);
product = signal<Product | null>(null);
async ngOnInit() {
// Método 1: Usar run() wrapper
await this.pendingTasks.run(async () => {
const data = await firstValueFrom(
this.http.get<Product>('/api/product/123')
);
this.product.set(data);
});
// Método 2: Manual add/remove
const cleanup = this.pendingTasks.add();
try {
const data = await firstValueFrom(
this.http.get<Product>('/api/product/456')
);
this.product.set(data);
} finally {
cleanup(); // Asegurar cleanup incluso si hay error
}
}
}
// Método 3: Para Observables, usar pendingUntilEvent
import { pendingUntilEvent } from '@angular/core/rxjs-interop';
export class DataComponent {
http = inject(HttpClient);
// SSR esperará hasta que este observable emita o complete
data$ = this.http.get('/api/data').pipe(
pendingUntilEvent()
);
}
El framework usa PendingTasks internamente para HttpClient y Router, así que la mayoría de operaciones estándar funcionan automáticamente. Configurar hydration con zoneless es straightforward:
// app.config.ts
import {
provideClientHydration,
withEventReplay,
withIncrementalHydration
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideClientHydration(
withEventReplay(), // Captura eventos durante hydration
withIncrementalHydration() // Angular 19+
)
]
};
Híbrido zoneful/zoneless: el mejor de ambos mundos temporalmente
Angular 18 introdujo un scheduler híbrido que soporta tanto notificaciones de Zone.js como notificaciones explícitas simultáneamente. Esto permite una migración gradual donde mantienes Zone.js instalado mientras migras componentes a patrones zoneless:
// Mantener Zone.js temporalmente
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ // NO zoneless
schedulingMode: NgZoneSchedulingMode.Default // Modo híbrido
})
]
};
// Los Signals funcionan incluso dentro de runOutsideAngular
ngZone.runOutsideAngular(() => {
httpClient.get<Post>('https://api.example.com/posts/1')
.pipe(delay(3000), map(res => res.title))
.subscribe(title => {
this.title.set(title); // Signal notifica al scheduler híbrido
});
});
Detección de problemas de compatibilidad en desarrollo:
Angular 20+ incluye provideCheckNoChangesConfig() para detectar código incompatible con zoneless durante desarrollo:
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideCheckNoChangesConfig({
exhaustive: true, // Verificación completa
interval: 1000 // Verificar cada segundo
})
]
};
Esta configuración ejecuta verificaciones periódicas buscando bindings que cambiaron sin notificación apropiada, lanzando ExpressionChangedAfterItHasBeenCheckedError para indicar código problemático. Es invaluable durante la migración para identificar componentes que necesitan refactoring.
El roadmap: hacia un Angular completamente reactivo
El equipo de Angular tiene una visión clara para el futuro post-zoneless. Angular 21 (mayo 2025) hará zoneless el modo por defecto en nuevos proyectos creados con ng new. El CLI preguntará si deseas mantener Zone.js para compatibilidad con código legacy, pero la opción recomendada será zoneless.
Las próximas innovaciones incluyen Signal-based Forms que reemplazarán ReactiveFormsModule con una API completamente basada en Signals, eliminando la dependencia de RxJS para formularios. El httpResource API (experimental en Angular 19) proporcionará una primitiva de alto nivel para requests HTTP que retorna un Signal con estados de loading, error y success integrados. Streamed SSR para zoneless permitirá serialización incremental del HTML a medida que se completan operaciones asíncronas, mejorando Time to First Byte (TTFB) dramáticamente.
La colaboración con Wiz (el framework interno de Google que impulsa Google Search, Photos, Payments y YouTube) está acelerando optimizaciones de rendimiento críticas. Wiz opera completamente sin Zone.js, y la convergencia técnica entre Angular y Wiz significa que las optimizaciones desarrolladas para las propiedades más visitadas del mundo fluirán directamente a Angular público.
Errores comunes y sus soluciones definitivas
Durante la migración a zoneless, ciertos errores aparecen repetidamente. El error NG0908 indica que Angular esperaba que todos los componentes usen OnPush:
Error: NG0908: In this configuration Angular expects all components to be 'OnPush'
La solución es agregar changeDetection: ChangeDetectionStrategy.OnPush a todos los componentes. Esto no es técnicamente obligatorio en zoneless, pero es altamente recomendado para máxima eficiencia.
El error NG0914 indica que Zone.js todavía se está cargando a pesar de estar en modo zoneless:
NG0914: Using zoneless but still loading Zone.js
Esto sucede cuando olvidaste remover Zone.js de angular.json, de archivos de polyfills, o cuando una dependencia está requiriendo Zone.js. Verifica todos los lugares mencionados anteriormente y asegura que npm uninstall zone.js fue ejecutado.
El problema más común no genera error: la vista simplemente no se actualiza después de operaciones asíncronas. Esto sucede porque el código asume detección automática de cambios:
// ❌ Código que no funciona
setTimeout(() => {
this.data = newValue; // Vista congelada
}, 1000);
// ✅ Solución universal: usar Signals
timeout(() => {
this.data.set(newValue); // Vista se actualiza
}, 1000);
// ✅ Alternativa: markForCheck
setTimeout(() => {
this.data = newValue;
this.cdr.markForCheck(); // Notificación manual
}, 1000);
Problemas con librerías de terceros son menos comunes pero más desafiantes. Si una librería depende internamente de Zone.js y no se actualiza, tienes tres opciones: buscar una alternativa actualizada, envolver su código con Signals y notificaciones manuales, o usar la librería az-zoneless (https://github.com/ywarezk/zoneless) que proporciona utilities y directives para manejar código legacy.
Decisión estratégica: ¿cuándo adoptar zoneless?
La adopción de zoneless debe basarse en la situación específica de tu proyecto y equipo. Proyectos nuevos iniciados en Angular 20.2+ deberían usar zoneless desde el inicio: es más simple que migrar posteriormente, ofrece mejor rendimiento desde el día uno, y prepara la aplicación para el futuro cuando zoneless sea el estándar.
Aplicaciones existentes grandes deben evaluar varios factores antes de migrar. Si la mayoría de componentes ya usan OnPush, si el estado se maneja con servicios reactivos (RxJS, NGRX, Signals), y si hay pocas dependencias de librerías antiguas, la migración será relativamente fluida. Por el contrario, si la app depende extensivamente de detección automática de cambios, usa muchas librerías legacy sin actualizar, o el equipo no tiene experiencia con Signals, la migración requerirá inversión significativa de tiempo.
Una estrategia de migración incremental es el enfoque más pragmático: habilita OnPush en nuevos componentes y componentes que edites, comienza a usar Signals para nuevo estado, convierte Observables a Signals gradualmente con toSignal(), audita y remueve usos de NgZone APIs incompatibles, y solo cuando una porción significativa del código sea compatible (70-80%), habilita zoneless completamente. Este enfoque distribuye el costo de migración a lo largo de varios sprints y reduce el riesgo.
La recompensa es clara: aplicaciones más rápidas, más pequeñas, más fáciles de debuggear, y alineadas con la dirección futura de Angular. Con Google migrando sus propiedades internas a zoneless y Angular 21 haciéndolo el estándar, la pregunta no es “si” adoptar zoneless, sino “cuándo” es el momento óptimo para tu equipo y proyecto.
Conclusión: un nuevo paradigma para Angular
Zoneless change detection representa mucho más que una optimización técnica: es un cambio filosófico fundamental en cómo Angular entiende la reactividad. El modelo de Zone.js, basado en interceptar automáticamente todo comportamiento asíncrono, fue revolucionario en 2016 cuando la reactividad de grano fino era compleja de implementar. Pero ocho años después, con Signals proporcionando primitivas reactivas ergonómicas, el overhead y las limitaciones de Zone.js superan sus beneficios.
El nuevo paradigma zoneless exige que los desarrolladores piensen explícitamente sobre flujos de datos: cuándo cambia el estado, qué componentes necesitan actualizarse, y cómo se propagan los cambios. Este conocimiento explícito no es una desventaja; al contrario, hace el código más predecible, testeable y optimizable. Los bugs relacionados con detección de cambios, que con Zone.js podían ser misteriosos y difíciles de reproducir, en zoneless son explícitos y rastreables directamente a la fuente.
La convergencia de Angular con Wiz señala que zoneless no es experimental ni opcional para el futuro del framework: es la arquitectura sobre la cual se construirán todas las innovaciones venideras. Signal-based forms, httpResource, streamed SSR, y optimizaciones de rendimiento avanzadas asumen zoneless como foundation. Los equipos que adopten zoneless ahora estarán mejor posicionados para aprovechar estas innovaciones cuando lleguen.
Para desarrolladores de Angular en 2025, dominar zoneless change detection, Signals, y patrones reactivos no es una habilidad especializada: es el núcleo de la práctica moderna de Angular. El fin de Zone.js marca el inicio de una era más rápida, más explícita, y más competitiva para el framework.




