Change Detection y rendimiento de runtime
Change Detection y rendimiento de runtime
Una app Angular puede tener una arquitectura impecable y aun así ir a los tirones. La causa casi nunca es “Angular es lento”: es que el motor de detección de cambios (change detection, CD) corre más veces de las necesarias, revisa más nodos de los necesarios, y ejecuta expresiones caras en cada pasada. Este capítulo es sobre el rendimiento de runtime: lo que pasa mientras el usuario interactúa, no en la carga inicial (eso va en el capítulo 7).
El objetivo es que entiendas qué dispara CD, qué subárbol revisa, y cuánto cuesta cada revisión, para después medir y recortar. Vas a ver los tres modelos de CD que conviven hoy (Zone.js clásico, OnPush, zoneless), por qué zoneless es la dirección del framework, y el catálogo de antipatrones que hacen que tu app haga trabajo invisible en cada tecla que apreta el usuario.
Qué es la detección de cambios
Angular mantiene sincronizado el estado de tus componentes con el DOM. La CD es el proceso que recorre el árbol de componentes, compara el valor actual de cada binding del template con el valor de la pasada anterior, y si cambió, actualiza el nodo del DOM. Ese “comparar contra el valor anterior” es el dirty checking.
El punto clave de rendimiento es doble:
- Cada cuánto corre la CD (la frecuencia).
- Cuánto del árbol revisa cada vez que corre (el alcance).
Los tres modelos difieren precisamente en esas dos dimensiones. Zone.js dispara mucho y revisa todo; OnPush dispara igual pero revisa menos; zoneless dispara solo cuando algo reactivo cambió y revisa solo lo afectado.
Modelo clásico: Zone.js
Históricamente Angular vino con Zone.js, una librería que monkey-patchea las APIs asíncronas del navegador: setTimeout, setInterval, addEventListener, Promise, fetch, XMLHttpRequest. Cada vez que una de esas APIs se resuelve, Zone.js le avisa a Angular “puede que algo haya cambiado” y Angular dispara una CD global.
“Global” es literal: por defecto (ChangeDetectionStrategy.Default) Angular recorre todo el árbol de componentes de arriba a abajo y hace dirty checking de todos los bindings, sin importar dónde ocurrió el evento. Si tenés 2.000 bindings en pantalla y el usuario mueve el mouse sobre un elemento con (mousemove), se revisan los 2.000 en cada movimiento.
El problema conceptual: Zone.js no sabe si el estado realmente cambió. Sabe que ocurrió algo asíncrono, y ante la duda revisa todo. Esa falta de información es la fuente de la CD excesiva del modelo clásico.
// Zone.js dispara CD global tras CADA uno de estos eventos,
// aunque no muten ningún estado que afecte al template:
setTimeout(() => {}, 1000); // -> CD global
elemento.addEventListener('scroll', () => {}); // -> CD global por cada scroll
fetch('/api/x').then(() => {}); // -> CD global al resolver
OnPush: recortar el alcance
ChangeDetectionStrategy.OnPush no cambia cuándo Angular considera correr CD, cambia qué subárboles revisa. Un componente OnPush se salta durante la CD salvo que ocurra una de estas condiciones:
- Cambia la referencia de un
input()(comparación por identidad, no por contenido). - Se dispara un evento desde el componente o alguno de sus hijos (un
(click), unoutput()). - El
asyncpipe de su template emite un valor (internamente llama amarkForCheck()). - Se lee en el template un signal cuyo valor cambió.
- Se lo marca manualmente con
ChangeDetectorRef.markForCheck(). - Se setea un input vía
ComponentRef.setInput().
Cuando algo marca un componente como “sucio”, Angular marca también toda la cadena de ancestros hasta la raíz (para poder llegar hasta él en el recorrido), pero solo revisa esa rama, no los hermanos ni sus subárboles.
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush, // <- clave
template: `<h3>{{ user().name }}</h3>`,
})
export class UserCardComponent {
user = input.required<{ name: string }>();
}
Con OnPush, la comparación por referencia de los inputs es el detalle que más gente tropieza: si mutás el objeto en lugar de reemplazarlo, la referencia no cambia y Angular no re-renderiza. Lo vemos en la sección de antipatrones.
Zoneless: la dirección del framework
Desde Angular 21 las aplicaciones nuevas son zoneless por defecto; en v22 esa es la base de trabajo. Zoneless significa eliminar Zone.js por completo y que la CD se dispare solo cuando el código le avisa a Angular que hubo un cambio reactivo, con un mecanismo de notificación explícito y coalescido (varias notificaciones seguidas se agrupan en una sola pasada).
Lo activás (si migrás una app vieja de v20) con:
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
bootstrapApplication(App, {
providers: [provideZonelessChangeDetection()],
});
Y quitás zone.js de los polyfills en angular.json (y del package.json). En apps v22 nuevas ya viene así.
Sin Zone.js, Angular necesita notificaciones explícitas para saber cuándo revisar. Las fuentes válidas son:
- Signals leídos en el template que cambian de valor (la vía principal y de grano fino).
- El
asyncpipe (llama amarkForCheck()al emitir). ChangeDetectorRef.markForCheck()manual.ComponentRef.setInput().- Los listeners de template y de host (
(click),@HostListener).
Todo lo que no pase por uno de esos canales no dispara CD. Por eso la regla práctica de zoneless es: tu estado tiene que ser reactivo (signals o observables consumidos con async), y cuando algo se actualiza por fuera de ese modelo (una librería de terceros, una mutación imperativa) tenés que llamar a markForCheck() a mano.
Dos consecuencias que la gente suele olvidar:
- Los reactive forms no agendan CD por sí solos al cambiar su modelo; conectá sus observables al template con
asynco usá signals. - En SSR, el trabajo asíncrono se rastrea con el servicio
PendingTasks(opendingUntilEvent()derxjs-interop) para que el servidor sepa cuándo la app está estable, ya que sin Zone.js no existeNgZone.onStable.
Para código que antes dependía de NgZone.onStable / isStable, los reemplazos son afterNextRender() (una vez) y afterEveryRender() (cada render). NgZone.run() y runOutsideAngular() siguen siendo válidos.
Por qué zoneless es mejor rendimiento
- Menos disparos: la CD corre por cambios reactivos reales, no por cada evento asíncrono del navegador.
- Grano fino: los signals le dicen a Angular exactamente qué componentes leyeron ese valor, así que la CD tiende a lo que cambió en vez de a “todo el árbol o toda una rama”.
- Bundle más chico y arranque más rápido: te ahorrás Zone.js (decenas de KB) y su costo de patching en el arranque, lo que ayuda a los Core Web Vitals (capítulo 7).
- Stack traces limpios: sin el monkey-patching de Zone.js, los errores son legibles.
flowchart TD
subgraph Zonejs["Modelo Zone.js (Default)"]
Z1["Evento async<br/>(setTimeout, click, fetch...)"] --> Z2["Zone.js: 'algo pasó'"]
Z2 --> Z3["CD GLOBAL<br/>recorre TODO el árbol"]
end
subgraph Zoneless["Modelo Zoneless + Signals"]
S1["signal.set / async pipe /<br/>markForCheck / listener"] --> S2["Notificación explícita<br/>(coalescida)"]
S2 --> S3["CD dirigida<br/>solo a lo marcado/afectado"]
end
Propagación de CD: Default vs OnPush
El siguiente diagrama muestra el mismo árbol de componentes cuando cambia el estado en el nodo D. Con Default, Angular revisa el árbol entero. Con OnPush, solo se revisa la cadena que llega hasta el nodo modificado y el propio nodo; los subárboles no involucrados se saltan.
flowchart TD
A["Root (App)"] --> B["Header"]
A --> C["Sidebar OnPush"]
A --> D["Content OnPush"]
D --> E["List OnPush"]
D --> F["Detail OnPush"]
C --> G["Menu OnPush"]
classDef checked fill:#2e7d32,color:#fff,stroke:#1b5e20;
classDef skipped fill:#555,color:#ddd,stroke:#333;
class A,D,E,F checked;
class B,C,G skipped;
En verde, los componentes que Angular revisa cuando un evento marca a Content (D): la raíz A (para poder llegar), D y sus hijos E y F. En gris, los que se saltan gracias a OnPush: Header, Sidebar y Menu no se tocan. Con Default en todos, los siete nodos se revisarían en cada CD.
La regla de diseño: poné OnPush en todos tus componentes (o adoptá zoneless, que efectivamente te obliga a pensar así). En una app zoneless con signals, la CD se acerca todavía más a “solo D y quien lea su signal”.
@for con track: identidad estable o pagás O(n)
El nuevo control flow @for exige la cláusula track. No es opcional como el trackBy clásico de *ngFor; el compilador te obliga.
// Correcto: identidad estable por id.
@for (item of items(); track item.id) {
<app-row [item]="item" />
}
track le dice a Angular cómo identificar cada elemento entre una render y la siguiente, para mantener la relación entre cada dato y su nodo del DOM. Con esa identidad, cuando la colección cambia Angular ejecuta el mínimo de operaciones de DOM: mueve, reutiliza o actualiza los nodos existentes en vez de destruirlos y recrearlos.
El costo de no tener identidad estable
Si trackeás por algo que cambia en cada render (o por el objeto entero cuando el backend te devuelve instancias nuevas), Angular no puede mapear qué dato corresponde a qué nodo, así que destruye y recrea todos los nodos del DOM en cada actualización: eso es O(n) en creación de elementos, más el costo de re-instanciar cada componente hijo, correr sus constructores y sus hooks de ciclo de vida. En una lista de 500 filas, un cambio trivial (agregar un ítem) puede recrear las 500 en vez de insertar una.
// Antipatrón: track por índice en una lista que se reordena o filtra.
// Al reordenar, el índice 0 sigue siendo 0 pero apunta a otro dato:
// Angular reutiliza el nodo equivocado o recrea de más.
@for (item of items(); track $index) {
<app-row [item]="item" />
}
// Antipatrón: sin identidad real; si el backend devuelve objetos nuevos,
// cada fetch recrea toda la lista.
@for (item of items(); track item) {
<app-row [item]="item" />
}
// Mejor: una clave única y estable del dominio.
@for (item of items(); track item.id) {
<app-row [item]="item" />
}
$index como track es válido solo para colecciones que nunca cambian de orden ni de contenido. Para todo lo demás, usá una clave única y estable (id, uuid). La diferencia con *ngFor es de fondo: @for prioriza reutilizar la vista; si la clave trackeada no cambió pero el objeto sí, actualiza bindings e inputs en vez de destruir el elemento.
Reducir el trabajo de render: menos nodos, más tarde
Independiente de cuántas veces corra la CD, cada pasada cuesta proporcional a cuántos nodos hay en pantalla. Tres herramientas para bajar ese N.
@defer: renderizar más tarde
Los bloques @defer posponen la carga y el render de una parte del template hasta que se cumple un disparador, y además separan ese código en un chunk aparte del bundle inicial. Sirve para todo lo que no es necesario en el primer render: contenido below the fold, componentes pesados, widgets de terceros.
@defer (on viewport) {
<app-comentarios [postId]="postId()" />
} @placeholder (minimum 500ms) {
<div class="skeleton">Cargando comentarios...</div>
} @loading (after 100ms; minimum 1s) {
<app-spinner />
} @error {
<p>No se pudieron cargar los comentarios.</p>
}
Disparadores disponibles: on idle (default), on viewport, on interaction, on hover, on timer(500ms), on immediate, y when <condición>. Podés precargar el chunk sin renderizar todavía con prefetch on idle. Solo difieren componentes, directivas y pipes standalone. El detalle de @defer aplicado a Core Web Vitals lo profundizamos en el capítulo 7.
Virtual scrolling del CDK: renderizar solo lo visible
Una lista de 10.000 ítems con @for crea 10.000 nodos (y 10.000 componentes hijos): O(n) en memoria y en costo de CD por pasada, aunque el usuario vea 20 a la vez. El virtual scroll del CDK renderiza solo los ítems visibles más un pequeño buffer, y reutiliza esos nodos a medida que scrolleás. El costo de render y de CD pasa a ser proporcional a lo que se ve (constante), no al total de la colección.
import { Component } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
selector: 'app-big-list',
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="48" class="viewport">
<div *cdkVirtualFor="let item of items" class="row">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: `.viewport { height: 400px; } .row { height: 48px; }`,
})
export class BigListComponent {
items = Array.from({ length: 10_000 }, (_, i) => ({ name: `Item ${i}` }));
}
itemSize (alto de cada fila en px) le permite al CDK calcular qué rango renderizar sin medir cada nodo. Regla: cualquier lista que pueda crecer sin techo (feeds, tablas, resultados de búsqueda) es candidata a virtual scroll.
NgOptimizedImage: imágenes sin castigar el runtime ni el CLS
NgOptimizedImage (directiva de @angular/common) optimiza la carga de imágenes. Se usa con ngSrc en lugar de src:
import { NgOptimizedImage } from '@angular/common';
@Component({
imports: [NgOptimizedImage],
template: `
<!-- Imagen LCP: se prioriza -->
<img ngSrc="hero.jpg" width="1200" height="600" priority alt="Portada" />
<!-- Imágenes secundarias: lazy load automático -->
<img ngSrc="thumb.jpg" width="200" height="150" alt="Miniatura" />
`,
})
export class GalleryComponent {}
Qué te da:
width/heightobligatorios: reservan el espacio y evitan layout shift (CLS), uno de los Core Web Vitals.priorityen la imagen LCP: aplicafetchpriority=high,loading=eagery genera unpreload.- Lazy loading automático (
loading=lazy) en las no prioritarias: no compiten con el render inicial. srcsetautomático para servir el tamaño adecuado según el viewport, yfillcuando no conocés las dimensiones (el padre debe serposition: relative/absolute/fixed).
Cómo medir la CD
No optimices a ciegas. Angular expone instrumentación para ver cuántas pasadas de CD ocurren y cuánto dura cada una.
Angular DevTools — Profiler
La extensión Angular DevTools tiene una pestaña Profiler. Grabás una interacción y te muestra una secuencia de barras: cada barra es un ciclo de CD, y la altura es cuánto tardó ese ciclo. Un flujo de barras altas ante una interacción trivial es la señal de CD excesiva.
En el flame graph de cada ciclo podés activar la casilla Change detection: resalta los componentes que efectivamente pasaron por CD y pinta en gris los que se saltaron (por ejemplo, componentes OnPush que no re-renderizaron). Es la forma directa de verificar que tu OnPush está funcionando: si un componente que esperabas gris aparece revisado, algo lo está marcando de más.
enableProfiling() y el track de Chrome DevTools
Desde Angular 20 podés integrar la instrumentación de Angular con el panel Performance de Chrome DevTools. Lo activás en la consola con:
ng.enableProfiling()
o en el arranque de la app importando enableProfiling() de @angular/core (solo funciona en modo desarrollo). Al grabar un perfil aparece un track “🅰️ Angular” con eventos propios del framework: ciclos de CD, creación y actualización de componentes, ejecución de hooks y de listeners, alineados con el timeline del navegador. El color-coding distingue puntos de entrada (CD), código del compilador y tu TypeScript (hooks, listeners). Sirve para ubicar el ciclo caro y bajar hasta la función concreta que lo hace lento.
Antipatrones de rendimiento de runtime
1. Funciones y getters en el binding del template
Toda expresión del template se reevalúa en cada CD. Si esa expresión es una llamada a función o a un getter, esa función se ejecuta en cada pasada, decenas de veces por segundo bajo interacción.
// Antipatrón: getFullPrice() corre en CADA CD, por cada fila.
// Con N filas y varias CD por segundo, son miles de ejecuciones inútiles.
@Component({
template: `
@for (p of products(); track p.id) {
<span>{{ getFullPrice(p) }}</span> <!-- función en binding -->
}
`,
})
export class BadComponent {
products = signal<Product[]>([]);
getFullPrice(p: Product) {
return p.base * (1 + this.taxRate) - p.discount; // se recalcula siempre
}
}
// Mejor: precalcular con computed() (memoizado, solo recalcula si cambian sus deps),
// o mapear una sola vez a un modelo de vista que ya trae el precio.
@Component({
template: `
@for (p of pricedProducts(); track p.id) {
<span>{{ p.fullPrice }}</span> <!-- lectura de propiedad, sin cómputo -->
}
`,
})
export class GoodComponent {
products = signal<Product[]>([]);
taxRate = signal(0.21);
pricedProducts = computed(() =>
this.products().map((p) => ({
...p,
fullPrice: p.base * (1 + this.taxRate()) - p.discount,
})),
);
}
Un computed() está memoizado: solo recalcula cuando cambia alguna de sus dependencias, no en cada CD. Esa es la diferencia de complejidad entre “una vez por cambio de dato” y “N veces por segundo”.
2. Pipes impuros
Un pipe puro solo se recalcula si cambia la referencia de su input; uno impuro (pure: false) se ejecuta en cada CD, igual que una función en el template. Solo usá pipes impuros cuando no haya alternativa, y jamás para lógica cara.
// Antipatrón: pipe impuro que filtra en cada CD -> O(n) por pasada.
@Pipe({ name: 'filterActive', pure: false })
export class FilterActivePipe implements PipeTransform {
transform(items: Item[]): Item[] {
return items.filter((i) => i.active); // recorre todo en cada CD
}
}
// Mejor: derivar la lista con computed() y consumirla directo.
activeItems = computed(() => this.items().filter((i) => i.active));
3. Mutar arrays/objetos sin cambiar la referencia (con OnPush)
Con OnPush (y en zoneless), los inputs se comparan por referencia. Si mutás en lugar de reemplazar, la referencia no cambia y el hijo no se entera: la UI queda desactualizada.
// Antipatrón: push muta el mismo array; la referencia no cambia,
// el hijo OnPush no re-renderiza.
addItem(item: Item) {
this.items().push(item); // <- misma referencia
}
// Mejor: crear una referencia nueva (inmutabilidad).
addItem(item: Item) {
this.items.update((list) => [...list, item]); // <- referencia nueva
}
Lo mismo aplica a objetos: this.user().name = 'x' no dispara nada; this.user.update(u => ({ ...u, name: 'x' })) sí. Trabajar de forma inmutable no es un capricho de estilo: es lo que hace que la CD por referencia funcione.
4. effects que disparan CD en cascada
Un effect() que escribe signals que otros componentes leen puede encadenar pasadas de CD: el effect corre, muta estado, eso agenda otra CD, que dispara otro effect, y así. Los effects son para sincronizar con el mundo externo (logging, localStorage, integraciones imperativas), no para derivar estado.
// Antipatrón: effect que deriva estado escribiendo otro signal.
// Cada cambio de a encadena una CD extra para propagar b.
effect(() => {
this.total.set(this.price() * this.qty()); // derivación disfrazada de effect
});
// Mejor: derivación pura con computed(), sin escribir signals ni CD en cascada.
total = computed(() => this.price() * this.qty());
Regla: si estás escribiendo un signal dentro de un effect a partir de otros signals, casi siempre querés un computed(). Reservá effect() para efectos secundarios reales. (El detalle de signals y effects está en el capítulo 4.)
Checklist
- Todos los componentes usan
ChangeDetectionStrategy.OnPush, o la app es zoneless. - La app arranca con
provideZonelessChangeDetection()(o es v22 por defecto) yzone.jsno está en los polyfills. - El estado que afecta al template es reactivo: signals o observables con
asyncpipe. - Cada
@fortrackea por una clave única y estable del dominio (id), no por$indexni por el objeto entero, salvo colecciones inmutables. - No hay llamadas a funciones ni getters con cómputo en los bindings del template; se usa
computed()o un modelo de vista precalculado. - No hay pipes impuros haciendo trabajo caro (filtros, ordenamientos); esa lógica vive en
computed(). - Con
OnPush/zoneless, los updates de estado crean referencias nuevas (inmutabilidad); nuncapush/mutación in-place. - Los
effect()no derivan estado escribiendo signals; solo hacen efectos secundarios reales. - Listas potencialmente grandes usan virtual scroll del CDK (
cdk-virtual-scroll-viewport+*cdkVirtualFor). - Contenido no crítico envuelto en
@defercon el disparador adecuado (on viewport,on interaction). - Imágenes con
NgOptimizedImage(ngSrc,width/height,priorityen la LCP). - Perfilaste con Angular DevTools Profiler y/o
ng.enableProfiling()para confirmar que la CD corre lo mínimo.
Con el runtime bajo control, el siguiente frente es la carga inicial: cuánto tarda la app en aparecer y volverse interactiva. Seguí con Carga inicial y Core Web Vitals →, donde @defer, SSR con hydration incremental y los bundle budgets atacan LCP, INP y CLS.