Módulo 23: Rendimiento y @defer
Objetivos del módulo
- Saber qué se mide cuando se habla de rendimiento: LCP, INP, CLS y tamaño del paquete de JavaScript
- Leer la salida de
ng build: paquete inicial, trozos diferidos, tamaño bruto y tamaño de transferencia - Sacar componentes del paquete inicial con vistas diferibles
@defer - Mostrar cada fase de la carga con
@placeholder,@loadingy@error, y evitar parpadeos conminimumyafter - Elegir disparador:
idle,viewport,interaction,hover,immediate,timerywhen - Adelantar la descarga con
prefetchsin adelantar el pintado - Averiguar por qué un componente no se difiere, aunque esté dentro de
@defer - Evitar los saltos de diseño que puede provocar un bloque diferido
- Probar bloques
@deferconDeferBlockBehavior.Manual - Optimizar imágenes con
NgOptimizedImage - Elegir bien el
trackde un@for, con el efecto medido - Vigilar el tamaño con presupuestos y analizar qué ocupa el paquete
- Aislar errores de pintado con
@boundary, que en 22.2 está en developer preview
1. Qué significa que una aplicación vaya rápido
«Va lenta» no es un diagnóstico. Una página puede tardar en aparecer, puede aparecer pronto pero no responder a los clics, o puede responder bien mientras los botones se mueven de sitio justo cuando vas a pulsarlos. Son tres problemas distintos, y cada uno se corrige de una forma.
Google agrupa estos tres problemas en las Core Web Vitals, tres métricas que miden la experiencia real de la persona que usa la página:
| Métrica | Qué mide | Objetivo |
|---|---|---|
| LCP (Largest Contentful Paint) | Cuánto tarda en pintarse el elemento más grande de la pantalla inicial | 2,5 s o menos |
| INP (Interaction to Next Paint) | Cuánto tarda la página en reaccionar a clics y teclas | 200 ms o menos |
| CLS (Cumulative Layout Shift) | Cuánto se mueve el contenido sin que nadie lo pida | 0,1 o menos |
Los objetivos se miden en el percentil 75 de las visitas, separando móvil y escritorio (web.dev). Es decir, no basta con que vaya bien en tu ordenador: tres de cada cuatro visitas deben cumplirlos.
En una aplicación Angular, estas métricas dependen sobre todo de cuatro cosas:
- El JavaScript inicial. El navegador debe descargarlo, analizarlo y ejecutarlo antes de pintar nada. Cuanto menos haya, antes empieza todo.
- Las imágenes. Suelen ser el elemento que marca el LCP.
- El trabajo de cada actualización. Si cada cambio recorre miles de nodos, la página tarda en responder y sube el INP.
- El espacio reservado. El contenido que llega tarde y empuja al resto sube el CLS.
Este módulo recorre esas cuatro palancas. La detección de cambios, que también influye en el INP, ya la viste en el módulo 09, y la carga perezosa de rutas en el módulo 10.
💡 Consejo: Mide antes de optimizar. El panel Performance y Lighthouse de las DevTools de Chrome te dan LCP, INP y CLS, y el Profiler de Angular DevTools te dice qué componentes tardan más. Optimizar a ciegas suele complicar el código sin mejorar nada que el usuario note.
2. Leer la salida de ng build
El punto de partida es la tabla que imprime ng build, que por defecto compila para producción. Vamos a trabajar con un panel de EMPRESA que muestra una tabla de empleados hecha con Angular Material (la tabla la viste en el módulo 20). Primero, sin ninguna optimización:
// src/app/panel/panel.ts
import { Component } from '@angular/core';
import { TablaEmpleados } from './tabla-empleados';
@Component({
selector: 'app-panel',
imports: [TablaEmpleados],
template: `
<h1>Panel de EMPRESA</h1>
<p>Bienvenida al panel. Aquí tienes el resumen del día.</p>
<app-tabla-empleados />
`,
})
export class Panel {}
// src/app/panel/tabla-empleados.ts
import { httpResource } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import { MatSortModule, Sort } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';
@Component({
selector: 'app-tabla-empleados',
imports: [MatTableModule, MatSortModule],
template: `
<table mat-table matSort [dataSource]="filas()" (matSortChange)="orden.set($event)">
<ng-container matColumnDef="nombre">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Nombre</th>
<td mat-cell *matCellDef="let e">{{ e.nombre }} {{ e.apellidos }}</td>
</ng-container>
<ng-container matColumnDef="oficio">
<th mat-header-cell *matHeaderCellDef>Oficio</th>
<td mat-cell *matCellDef="let e">{{ e.oficio }}</td>
</ng-container>
<ng-container matColumnDef="salario">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Salario</th>
<td mat-cell *matCellDef="let e">{{ e.salario }} €</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columnas"></tr>
<tr mat-row *matRowDef="let fila; columns: columnas"></tr>
</table>
`,
})
export class TablaEmpleados {
protected readonly columnas = ['nombre', 'oficio', 'salario'];
protected readonly orden = signal<Sort>({ active: 'salario', direction: 'desc' });
private readonly datos = httpResource<Pagina<Empleado>>(() => `${API}/empleados?tamano=10`);
protected readonly filas = computed(() => {
const lista = [...(this.datos.value()?.contenido ?? [])];
const { active, direction } = this.orden();
if (!direction) return lista;
const signo = direction === 'asc' ? 1 : -1;
return lista.sort((a, b) =>
active === 'salario' ? signo * (a.salario - b.salario) : signo * a.nombre.localeCompare(b.nombre),
);
});
}
Los ficheros api.ts y modelos.ts son los de los módulos anteriores: API vale 'http://localhost:3000/api', y Empleado y Pagina describen las respuestas de la API. Compilamos:
ng build
Initial chunk files | Names | Raw size | Estimated transfer size
main-WCY72KP7.js | main | 257.54 kB | 69.16 kB
styles-WRVNRQGL.css | styles | 93 bytes | 93 bytes
| Initial total | 257.63 kB | 69.25 kB
Cada columna cuenta algo distinto:
| Columna | Qué significa |
|---|---|
| Initial chunk files | Lo que el navegador necesita antes de arrancar la aplicación |
| Lazy chunk files | Trozos que se descargan más tarde, si hacen falta (aquí aún no hay ninguno) |
| Raw size | Tamaño del fichero en disco. Es lo que el navegador debe analizar y ejecutar |
| Estimated transfer size | Lo que viaja por la red una vez comprimido. Es lo que tarda la descarga |
📘 Concepto: Un chunk o trozo es un fichero de JavaScript que produce el empaquetador. El paquete (bundle) inicial es la suma de los trozos iniciales. Mirar solo el tamaño comprimido engaña: 69 kB se descargan rápido, pero el móvil tiene que ejecutar 257 kB.
Fíjate en el detalle más importante: todo está en el paquete inicial, incluida una tabla que quizá está por debajo del borde de la pantalla y que algunas personas nunca llegarán a ver.
3. Vistas diferibles con @defer
Un bloque @defer le dice a Angular: «este trozo de plantilla, y el código que necesita, no hacen falta ahora; tráelos cuando pase algo». Solo cambia la plantilla del panel:
// src/app/panel/panel.ts
import { Component } from '@angular/core';
import { TablaEmpleados } from './tabla-empleados';
@Component({
selector: 'app-panel',
imports: [TablaEmpleados],
template: `
<h1>Panel de EMPRESA</h1>
<p>Bienvenida al panel. Aquí tienes el resumen del día.</p>
@defer (on viewport) {
<app-tabla-empleados />
} @placeholder {
<p>La tabla se cargará al llegar aquí.</p>
}
`,
})
export class Panel {}
El import y el array imports no cambian. No hay rutas nuevas, ni import() escrito a mano. Y aun así la salida de ng build es otra:
Initial chunk files | Names | Raw size | Estimated transfer size
chunk-DFNS52KH.js | - | 195.64 kB | 56.79 kB
main-6LEJCYGG.js | main | 699 bytes | 699 bytes
styles-WRVNRQGL.css | styles | 93 bytes | 93 bytes
| Initial total | 196.44 kB | 57.58 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-T2PHIITA.js | tabla-empleados | 70.22 kB | 15.95 kB
| Inicial (bruto) | Inicial (transferencia) | Diferido | |
|---|---|---|---|
Sin @defer | 257,63 kB | 69,25 kB | Nada |
Con @defer | 196,44 kB | 57,58 kB | 70,22 kB (15,95 kB) |
El paquete inicial baja un 24 %, y la tabla se convierte en un fichero aparte llamado tabla-empleados. Ese fichero contiene el componente y todo lo que solo él usaba: MatTableModule, MatSortModule y las partes del CDK de las que dependen.
Figura 1. @defer separa el código en tiempo de compilación, sin cambios en tu TypeScript.
Qué ocurre por dentro
La magia ocurre en el compilador de Angular, no en el navegador:
- El compilador ve que
TablaEmpleadossolo se usa dentro del bloque@defer. - Elimina el
importestático de ese componente del código generado y lo cambia por unimport()dinámico. - esbuild, el empaquetador de
ng build, ve eseimport()y crea un trozo aparte. - En ejecución, Angular pinta el marcador y prepara el disparador. Cuando salta, llama al
import(), espera y pinta el contenido.
Si un componente se difiere dentro de otro, los datos también llegan tarde: httpResource no pide nada hasta que el componente existe. Así que @defer ahorra también peticiones a la API de quien nunca baja hasta la tabla.
📘 Concepto:
@deferfrente aloadComponent. Las dos crean trozos separados, pero a distinta escala.loadComponentdivide por páginas: el trozo se pide al navegar.@deferdivide dentro de una página: un panel, una gráfica, un editor, unos comentarios. Son complementarios y conviene usar los dos.
El motor también ocupa
Comparando los paquetes con detalle aparece un dato útil: en este ejemplo, usar @defer añade unos 7 kB sin comprimir a la parte de @angular/core (el código que gestiona disparadores y estados). Por eso la suma total crece un poco.
⚠️ Importante: Diferir tiene sentido cuando el contenido pesa de verdad (tablas, gráficas, editores, mapas) y no se ve al principio. Diferir un componente de 2 kB que ocupa el centro de la pantalla empeora las cosas: gastas una petición más y haces esperar por algo que debería verse ya.
4. Las fases: @placeholder, @loading y @error
Un bloque @defer pasa por varios estados, y cada uno tiene su propio bloque de plantilla, todos opcionales:
Figura 2. Los estados de un bloque @defer, siempre hacia delante.
Este ejemplo los usa todos. Es una gráfica de salarios medios por departamento que se descarga al pulsar un botón:
// src/app/estados/estados.ts
import { Component } from '@angular/core';
import { GraficaSalarios } from './grafica-salarios';
@Component({
selector: 'app-estados',
imports: [GraficaSalarios],
template: `
<h2>Salarios por departamento</h2>
<button type="button" #boton>Ver gráfica</button>
<div class="zona" aria-live="polite">
@defer (on interaction(boton)) {
<app-grafica-salarios />
} @placeholder (minimum 300ms) {
<p class="aviso">Pulsa el botón para cargar la gráfica.</p>
} @loading (after 100ms; minimum 500ms) {
<p class="aviso cargando">Descargando la gráfica...</p>
} @error {
<p class="aviso error">No se pudo descargar la gráfica. Recarga la página.</p>
}
</div>
`,
styles: `
.zona { min-height: 260px; margin-top: 12px; }
.aviso { padding: 16px; background: #eceff1; border-radius: 6px; }
.cargando { background: #e8eaf6; }
.error { background: #ffebee; color: #b71c1c; }
`,
})
export class Estados {}
// src/app/estados/grafica-salarios.ts
import { httpResource } from '@angular/common/http';
import { Component, computed } from '@angular/core';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';
@Component({
selector: 'app-grafica-salarios',
template: `
@for (fila of medias(); track fila.departamento) {
<div class="fila">
<span class="etiqueta">{{ fila.departamento }}</span>
<span class="barra" [style.width.px]="fila.ancho"></span>
<span>{{ fila.media }} €</span>
</div>
}
`,
styles: `
.fila { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
.etiqueta { width: 150px; font-size: 14px; }
.barra { height: 16px; background: #3f51b5; border-radius: 3px; }
`,
})
export class GraficaSalarios {
private readonly datos = httpResource<Pagina<Empleado>>(() => `${API}/empleados?tamano=100`);
protected readonly medias = computed(() => {
const grupos = new Map<string, number[]>();
for (const e of this.datos.value()?.contenido ?? []) {
const nombre = e.departamento ?? 'Sin departamento';
grupos.set(nombre, [...(grupos.get(nombre) ?? []), e.salario]);
}
const filas = [...grupos].map(([departamento, s]) => ({
departamento,
media: Math.round(s.reduce((a, b) => a + b, 0) / s.length),
}));
const maximo = Math.max(1, ...filas.map((f) => f.media));
return filas
.map((f) => ({ ...f, ancho: Math.round((f.media / maximo) * 320) }))
.sort((a, b) => b.media - a.media);
});
}
Para ver el estado de carga hemos retrasado la descarga con Playwright, porque en local tarda tan poco que no da tiempo a leerlo. Estas son las pantallas reales:

Figura 3. @placeholder: lo que se ve antes del disparador.

Figura 4. @loading: el trozo se está descargando.

Figura 5. El contenido principal, ya descargado y con datos de la API.

Figura 6. @error: la descarga del trozo ha fallado (la hemos bloqueado en el navegador).
minimum y after: evitar el parpadeo
En una red rápida, la carga dura 30 ms. Sin ninguna precaución, el usuario vería aparecer y desaparecer un aviso en un instante, algo que resulta más molesto que una espera tranquila. Por eso hay dos parámetros:
| Parámetro | Dónde | Efecto |
|---|---|---|
minimum 300ms | @placeholder | Mantiene el marcador visible al menos ese tiempo |
after 100ms | @loading | Espera antes de mostrar el aviso. Si la carga acaba antes, el aviso no llega a verse |
minimum 500ms | @loading | Si el aviso se llega a mostrar, sigue visible al menos ese tiempo |
El tiempo se escribe en ms o s. Con @loading (after 100ms; minimum 500ms), una descarga de 30 ms no muestra aviso alguno, y una de 150 ms lo muestra durante medio segundo, sin destellos.
Lo que hay que saber de @error
El @error de un @defer tiene un alcance muy concreto: solo se muestra si falla la descarga del trozo. Si el componente se descarga bien y luego lanza una excepción al pintarse, este bloque no hace nada. Para eso está @boundary, en la sección 15.
Dos comportamientos que hemos comprobado bloqueando la descarga:
- Sin bloque
@error, el marcador se queda ahí para siempre. El usuario no recibe ninguna explicación, y a tuErrorHandlerllega el errorNG0750: Loading dependencies for @defer block failed, but no @error block was configured. - No hay reintento. Tras el fallo, aunque la red vuelva y pulses otra vez el botón, el bloque sigue en
@error. Por eso el mensaje del ejemplo pide recargar la página.
⚠️ Importante: Este fallo es más habitual de lo que parece. Si publicas una versión nueva mientras alguien tiene la pestaña abierta, los ficheros antiguos con su hash pueden haber desaparecido del servidor. Cuando salte el disparador, el trozo dará 404. Escribe siempre
@error, y conserva los ficheros de la versión anterior durante un tiempo en tu despliegue.
Dependencias de los bloques auxiliares
Solo se difiere lo que hay en el contenido principal. Los componentes que uses en @placeholder, @loading y @error se cargan con el paquete inicial, porque deben estar listos antes que nada. Un <mat-spinner> en @loading mete MatProgressSpinner en el paquete inicial. Si tu objetivo era adelgazarlo, usa HTML y CSS sencillos en estos bloques.
5. Disparadores: cuándo se carga
La parte on ... decide cuándo salta el bloque. Angular tiene estos disparadores:
| Disparador | Salta cuando… | Uso típico |
|---|---|---|
| (ninguno) | Igual que on idle | Contenido secundario sin más requisitos |
on idle | El navegador está libre (requestIdleCallback) | Lo que no corre prisa |
on immediate | Justo después del pintado inicial | Algo pesado que se ve siempre |
on timer(2s) | Pasa ese tiempo | Avisos que aparecen más tarde |
on viewport | El marcador entra en pantalla (IntersectionObserver) | Contenido bajo el borde de la pantalla |
on interaction | Clic o tecla sobre el marcador | Paneles que se abren al pulsar |
on hover | El ratón pasa por encima o recibe el foco (mouseover, focusin) | Tarjetas de vista previa |
when condición | La expresión se hace verdadera | Permisos, o cualquier señal propia |
Hemos montado una página con un bloque de cada tipo. Cada componente diferido escribe en la consola cuándo se crea, y este es el resultado, a los 2,6 segundos de cargar:

Figura 7. Los disparadores automáticos ya han saltado. Los que dependen del usuario siguen en su marcador.
Por orden, immediate saltó el primero, idle llegó 17 ms después y timer(2s) casi dos segundos más tarde, porque el temporizador cuenta desde que se pinta el bloque. El tiempo exacto de idle depende de lo ocupado que esté el navegador.
<!-- Sin paréntesis: usa el marcador. Exige un @placeholder con un solo elemento raíz -->
@defer (on viewport) {
<app-tabla-empleados />
} @placeholder {
<p>La tabla se cargará al llegar aquí.</p>
}
<!-- Con referencia de plantilla: vigila otro elemento -->
<button type="button" #boton>Ver gráfica</button>
@defer (on interaction(boton)) {
<app-grafica-salarios />
}
<!-- Con señal: lo normal para condiciones propias -->
@defer (when usuarioEsAdmin()) {
<app-panel-admin />
}
Combinar disparadores
Se separan con ; y funcionan como un O: basta con que salte uno.
@defer (on interaction(combi); on timer(30s); when activo()) {
<app-marca nombre="combinado" />
}
Este bloque se carga con un clic, o a los 30 segundos, o cuando se active la señal. En la prueba saltó al activar la señal, sin esperar a los otros dos.
when solo va en un sentido
when no es un @if. Una vez cargado, el contenido se queda aunque la condición vuelva a ser falsa. Lo hemos comprobado: al poner la señal a true y luego a false, el componente sigue en la página.
📘 Concepto: Si necesitas mostrar y ocultar, combina los dos:
@if (visible()) { @defer { <app-x /> } }. El@ifcontrola la presencia y el@deferla descarga, que solo ocurre una vez.
viewport con margen
Con on viewport la descarga empieza cuando el elemento ya está en pantalla, así que siempre llegas un poco tarde. Puedes adelantarte pasando un objeto con opciones del IntersectionObserver:
<div #previo>...</div>
@defer (on viewport({ trigger: previo, rootMargin: '300px' })) {
<app-tabla-empleados />
} @placeholder {
<p>Cargando tabla...</p>
}
trigger indica el elemento que se vigila y rootMargin amplía la zona: el bloque salta cuando el elemento está a 300 px de entrar en pantalla, de modo que la tabla ya está lista cuando el usuario llega. La documentación indica que se admiten las demás opciones de IntersectionObserver, salvo root.
idle con tiempo máximo 🆕 v22.0
🆕 Desde Angular 22.0.
on idle(1500)obliga a cargar tras ese tiempo aunque el navegador siga ocupado. No existe en la LTS (Angular 21), donde daNG5002: "idle" trigger cannot have parameters. Si usas la LTS: combina dos disparadores,on idle; on timer(1500ms), que da el mismo resultado.
on idle sin más puede tardar mucho si la página está siempre ocupada. Con idle(1500), Angular espera a que el navegador esté libre, pero no más de 1500 ms. Además, en Angular 22 puedes cambiar qué significa «estar libre» en toda la aplicación.
Personalizar idle 🆕 v22.0
🆕 Desde Angular 22.0.
provideIdleServiceWithsustituye el servicio que decide cuándo está libre el navegador. No existe en la LTS (Angular 21). Si usas la LTS: no hay alternativa directa;on idleusa siemprerequestIdleCallback. Para otro criterio, usawhencon tu propia señal.
El servicio y onIdle se explican en el módulo 08.
Errores que detecta el compilador
Muchos fallos con disparadores no llegan a ejecución, y ng build los señala:
| Plantilla | Resultado |
|---|---|
@defer (on viewport) { ... } sin @placeholder | Error NG8019: un disparador sin destino necesita un marcador |
@defer (on hover) { ... } @placeholder { <p>uno</p> <p>dos</p> } | Error NG8020: el marcador debe tener exactamente un elemento raíz |
@defer (when activo) { ... }, siendo activo una señal | Error TS2774: has olvidado los paréntesis, y una función siempre es verdadera |
6. Adelantar la descarga con prefetch
Con on interaction, el usuario hace clic y espera a que baje el código. Si hay una pista de que va a pulsar, como tener el ratón encima, puedes descargar antes y pintar después. Para eso se separan los dos momentos:
// src/app/prefetch/prefetch.ts
import { Component } from '@angular/core';
import { GraficaSalarios } from '../estados/grafica-salarios';
@Component({
selector: 'app-prefetch',
imports: [GraficaSalarios],
template: `
<button type="button" #boton>Ver gráfica</button>
@defer (on interaction(boton); prefetch on hover(boton)) {
<app-grafica-salarios />
} @placeholder {
<p>Pasa por encima y luego pulsa.</p>
}
`,
})
export class Prefetch {}
Figura 8. Con prefetch, el tiempo de descarga se gasta antes del clic.
Contando las peticiones en un navegador real:
| Acción | Peticiones de trozos | ¿Gráfica en la página? |
|---|---|---|
| Pasar el ratón por encima | 1 | No |
| Pulsar el botón | 0 | Sí, al momento |
prefetch admite los mismos disparadores que on, además de prefetch when condición.
💡 Consejo: Una combinación muy útil para contenido bajo la pantalla es
@defer (on viewport; prefetch on idle): aprovecha el tiempo libre para tener el código ya en la caché. A cambio, descargas datos que quizá nadie use, lo que en conexiones móviles de pago tiene un coste para el usuario.
Aviso por prefetch sin disparador principal 🔄 v22.0
🔄 Cambia en Angular 22.0.
@defer (prefetch on idle)sin disparador principal da el avisoNG8021al compilar. En la LTS (Angular 21) compila sin avisos. Si usas la LTS: escribe siempre unon ...junto alprefetch, para que se comporte como esperas.
El aviso existe porque, sin disparador principal, el bloque usa idle, y entonces el prefetch sobra: se descarga y se pinta casi a la vez.
[WARNING] NG8021: Define a main trigger when using 'prefetch' triggers. Without an explicit
main trigger, @defer defaults to 'idle' and prefetch may have no effect.
hydrate: una tercera familia
Si la aplicación se renderiza en el servidor, existe otra familia de disparadores: hydrate on viewport, hydrate on interaction, etc. Sin ellos, el servidor pinta solo el marcador. Con ellos, pinta el contenido y lo activa en el cliente más tarde. Se ve en el módulo 24, junto a la hidratación incremental.
7. Cuando @defer no difiere
Este es el error más frecuente con @defer: escribes el bloque, todo funciona y el componente sigue en el paquete inicial. No hay ningún aviso. Solo lo notas si miras la salida de ng build.
Para que algo se difiera, debe cumplir dos condiciones:
- Debe ser standalone. Lo que se declara en un NgModule se carga siempre al principio.
- No debe usarse en ninguna otra parte del mismo fichero, fuera del bloque
@defer.
Vamos a romper la segunda condición de dos maneras. Primera, con una consulta de vista:
export class Panel {
// Esta referencia al VALOR de la clase impide diferirla
protected readonly tabla = viewChild(TablaEmpleados);
}
Segunda, usando el componente también fuera del bloque, aunque sea en un @if que nunca se cumple:
@defer (on viewport) {
<app-tabla-empleados />
} @placeholder {
<p>La tabla se cargará al llegar aquí.</p>
}
@if (mostrarOtra()) {
<app-tabla-empleados />
}
Estas son las mediciones de las cuatro versiones:
| Versión | Inicial (bruto) | Inicial (transferencia) | Trozo diferido |
|---|---|---|---|
Sin @defer | 257,63 kB | 69,25 kB | No |
Con @defer | 196,44 kB | 57,58 kB | Sí |
Con @defer y viewChild(TablaEmpleados) | 266,30 kB | 72,03 kB | No |
Con @defer y otro uso en plantilla | 265,47 kB | 71,72 kB | No |
Las dos últimas compilan sin avisos y ocupan más que no usar @defer: conservan el código de la tabla y además pagan el motor de los bloques diferidos.
⚠️ Importante: Comprueba siempre que el trozo aparece en Lazy chunk files. Si tu componente no sale ahí, algo lo está cargando antes.
Otras causas comunes:
- Ficheros de barril. Si importas desde un
index.tsque reexporta muchas cosas, el empaquetador puede arrastrar el componente al paquete principal. Importa desde su fichero:./tabla-empleados, no./panel. - Otro componente lo importa de forma normal. Si otra parte de la aplicación incluida en el paquete inicial usa
TablaEmpleados, su código ya está ahí. El bloque seguirá funcionando, pero no ahorra nada. - Dependencias compartidas. Si
MatTableModuletambién se usa en una pantalla inicial, se queda en el paquete inicial. Solo se difiere lo exclusivo del contenido diferido.
Cuidado con HMR
Con la recarga en caliente (HMR), que ng serve activa por defecto, la documentación indica que todos los trozos se cargan al principio y los disparadores se ignoran. Si quieres ver cómo se comporta @defer en desarrollo, lanza el servidor así:
ng serve --no-hmr
Todas las pruebas de este módulo se han hecho con esa opción.
8. Evitar los saltos de diseño
Un bloque diferido cambia de tamaño al cargarse. Si el marcador mide 40 px y el contenido 300, todo lo que hay debajo salta 260 px de golpe. Eso es exactamente lo que penaliza el CLS, y es lo que hace que pulses el botón equivocado.
Vamos a medirlo con dos componentes casi iguales, usando un PerformanceObserver del tipo layout-shift:
<!-- Sin reservar espacio -->
@defer (on timer(600ms)) {
<app-grafica-salarios />
} @placeholder {
<p>Cargando...</p>
}
<p class="debajo">Este párrafo baja de golpe cuando llega la gráfica.</p>
<!-- Reservando espacio -->
<div class="reserva">
@defer (on timer(600ms)) {
<app-grafica-salarios />
} @placeholder {
<p>Cargando...</p>
}
</div>
<p class="debajo">Este párrafo no se mueve.</p>
.reserva { min-height: 280px; }
| Versión | CLS medido |
|---|---|
| Sin reservar espacio | 0,1186 |
Con min-height | 0,0000 |
Con un solo bloque ya se supera el objetivo de 0,1. La corrección es una línea de CSS.

Figura 9. Sin reserva: los párrafos han bajado al llegar la gráfica, con un CLS de 0,1186.

Figura 10. Con min-height: la gráfica ocupa el hueco previsto y nada se mueve.
Reglas prácticas:
- Da al marcador o a su contenedor el mismo tamaño que tendrá el contenido.
- No uses
immediate,timerniviewporten contenido visible en la carga inicial sin reservar espacio. - Un contenido que aparece por debajo del borde de la pantalla no cuenta para el CLS mientras no se vea, así que
on viewportconrootMargines de lo más seguro. - Los cambios que ocurren justo después de una interacción del usuario no cuentan como inesperados: por eso
on interactiones tan cómodo, si la descarga es rápida.
Accesibilidad
Un lector de pantalla no anuncia solo que el contenido ha cambiado. Por eso el contenedor del ejemplo de la sección 4 lleva aria-live="polite", que hace que el cambio se lea cuando termine de hablar. Tienes más sobre esto en el módulo 21.
9. Probar bloques @defer
En las pruebas, por defecto, los bloques @defer se comportan como en el navegador (DeferBlockBehavior.Playthrough). Para comprobar cada estado sin simular clics ni esperas, usa el modo manual:
// src/app/estados/estados.spec.ts
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { Estados } from './estados';
describe('Estados', () => {
beforeEach(() => {
TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Manual,
providers: [provideHttpClient(), provideHttpClientTesting()],
});
});
it('pasa del marcador a la carga y al contenido', async () => {
const fixture = TestBed.createComponent(Estados);
await fixture.whenStable();
const elemento: HTMLElement = fixture.nativeElement;
expect(elemento.textContent).toContain('Pulsa el botón');
const [bloque] = await fixture.getDeferBlocks();
await bloque.render(DeferBlockState.Loading);
expect(elemento.textContent).toContain('Descargando');
await bloque.render(DeferBlockState.Complete);
expect(elemento.querySelector('app-grafica-salarios')).not.toBeNull();
});
it('muestra el aviso cuando falla la descarga', async () => {
const fixture = TestBed.createComponent(Estados);
await fixture.whenStable();
const [bloque] = await fixture.getDeferBlocks();
await bloque.render(DeferBlockState.Error);
expect(fixture.nativeElement.textContent).toContain('No se pudo descargar');
});
});
Test Files 1 passed (1)
Tests 2 passed (2)
fixture.getDeferBlocks() devuelve un DeferBlockFixture por bloque, y render lo lleva al estado que le pidas. Importa DeferBlockBehavior y DeferBlockState desde @angular/core/testing.
⚠️ Importante: Los estados también van solo hacia delante en las pruebas. Nuestra primera versión de esta prueba pasaba por
Errory luego pedíaComplete, y el contenido nunca aparecía:Errores el último estado. Por eso son dos pruebas separadas.
El módulo de pruebas completo es el módulo 26.
10. Imágenes con NgOptimizedImage
Las imágenes suelen ser lo que más pesa en una página, y lo que marca el LCP. <img> no te obliga a nada: puedes olvidar las medidas, que provocan saltos, o cargar con la misma prioridad la foto principal y una miniatura del pie. La directiva NgOptimizedImage aplica las buenas prácticas de forma automática y te avisa cuando no las sigues.
// src/app/imagenes/imagenes.ts
import { IMAGE_LOADER, ImageLoaderConfig, NgOptimizedImage } from '@angular/common';
import { Component } from '@angular/core';
@Component({
selector: 'app-imagenes',
imports: [NgOptimizedImage],
providers: [
{
provide: IMAGE_LOADER,
useValue: (config: ImageLoaderConfig) =>
`/img/${config.src}-${config.width ?? 640}.png`,
},
],
template: `
<h2>Oficina central</h2>
<img
id="portada"
ngSrc="oficina"
width="1280"
height="720"
sizes="(max-width: 700px) 100vw, 640px"
ngSrcset="640w, 1280w"
priority
alt="Vista de la oficina central"
/>
<h2>Con relleno</h2>
<div class="caja">
<img ngSrc="oficina" fill sizes="320px" ngSrcset="640w, 1280w" alt="Oficina en un marco" />
</div>
<div class="espacio"></div>
<h2>Abajo</h2>
<img ngSrc="oficina" width="640" height="360" ngSrcset="640w" alt="Sala de reuniones" />
`,
styles: `
#portada { max-width: 640px; width: 100%; height: auto; }
.caja { position: relative; width: 320px; height: 120px; }
.caja img { object-fit: cover; }
.espacio { height: 1200px; }
`,
})
export class Imagenes {}
Este es el HTML real que acaba en la página para la imagen principal:
<img id="portada" ngsrc="oficina" width="1280" height="720"
sizes="(max-width: 700px) 100vw, 640px" ngsrcset="640w, 1280w" priority=""
alt="Vista de la oficina central"
loading="eager" fetchpriority="high" decoding="sync" ng-img="true"
src="/img/oficina-640.png"
srcset="/img/oficina-640.png 640w, /img/oficina-1280.png 1280w">
Y este, el de la imagen del final de la página, sin priority:
<img ngsrc="oficina" width="640" height="360" ngsrcset="640w" alt="Sala de reuniones"
loading="lazy" fetchpriority="auto" decoding="auto" ng-img="true"
src="/img/oficina-640.png" srcset="/img/oficina-640.png 640w" sizes="auto, 100vw">
| Atributo | Para qué sirve |
|---|---|
ngSrc | Sustituye a src. Es el nombre que recibe el cargador |
width y height | Obligatorios, salvo con fill. Reservan el espacio y evitan saltos |
priority | Para la imagen principal: añade loading="eager", fetchpriority="high" y decoding="sync". En SSR genera además un <link rel="preload"> |
fill | La imagen rellena su contenedor, que debe tener position: relative, fixed o absolute. Se controla con object-fit |
ngSrcset | Genera un srcset a partir del cargador |
sizes | Indica qué ancho ocupará la imagen. En imágenes perezosas se antepone auto |
placeholder | Muestra una versión borrosa de poca resolución mientras carga. Exige un cargador, o un data: en base64 |
Sin priority, la imagen usa loading="lazy": el navegador no la descarga hasta que se acerca a la pantalla. Es el comportamiento que quieres para todas, salvo para la que marca el LCP.
El cargador de imágenes
Un cargador (loader) es una función que convierte el nombre y el ancho en una URL. Un CDN de imágenes genera cualquier tamaño a demanda, así que se le pide ?w=640. En el ejemplo, nuestro cargador apunta a ficheros que ya hemos generado.
Angular trae cargadores para varios servicios: provideCloudflareLoader, provideCloudinaryLoader, provideImageKitLoader, provideImgixLoader y provideNetlifyLoader. Si no configuras ninguno, se usa el genérico, que no transforma nada: ngSrc debe ser la ruta completa de la imagen y no se genera srcset adaptado.
Avisos en desarrollo
La directiva comprueba lo que declaras contra la imagen real:
| Situación | Resultado real en desarrollo |
|---|---|
Faltan width y height | Error NG02954, que llega a tu ErrorHandler |
| Las medidas no coinciden con la proporción real | Aviso NG02952: la imagen se verá deformada |
La imagen más grande no tiene priority | Aviso NG02955: «…this image is the Largest Contentful Paint (LCP) element but was not marked “priority”» |
💡 Consejo: Abre la consola con la aplicación en marcha, carga cada página y corrige estos avisos. Son una auditoría de imágenes gratuita. Si el LCP es una imagen, sus datos los encuentra el navegador real, no una regla estática.
⚠️ Importante:
NgOptimizedImageno comprime tus imágenes. Una imagen de 4 MB seguirá pesando 4 MB. Para reducir el peso necesitas un CDN, o generar tú mismo versiones en AVIF o WebP. Tampoco admitebackground-image(usafill) ni<picture>.
11. track: el coste de pintar listas
@for necesita track para saber qué elemento de la lista nueva corresponde a cada nodo existente del DOM. Crear nodos es caro, y moverlos o actualizarlos es barato. Un buen track decide entre lo uno y lo otro.
Hemos comparado las tres opciones habituales con la misma lista de cuatro empleados. Cada fila tiene un <input> para ver si conserva su estado:
// src/app/track/track.ts
import { Component, signal } from '@angular/core';
interface Fila {
id: number;
nombre: string;
}
const iniciales: Fila[] = [
{ id: 100, nombre: 'Ana' },
{ id: 101, nombre: 'Luis' },
{ id: 102, nombre: 'Marta' },
{ id: 103, nombre: 'Pedro' },
];
@Component({
selector: 'app-track',
template: `
<div class="botones">
<button type="button" (click)="recargar()">Recargar (objetos nuevos)</button>
<button type="button" (click)="invertir()">Invertir</button>
<button type="button" (click)="quitarPrimero()">Quitar el primero</button>
</div>
<div class="columnas">
<section>
<h3><code>track f.id</code></h3>
<ul>
@for (f of filas(); track f.id) {
<li>{{ f.nombre }} <input aria-label="Nota" /></li>
}
</ul>
</section>
<section>
<h3><code>track $index</code></h3>
<ul>
@for (f of filas(); track $index) {
<li>{{ f.nombre }} <input aria-label="Nota" /></li>
}
</ul>
</section>
<section>
<h3><code>track f</code></h3>
<ul>
@for (f of filas(); track f) {
<li>{{ f.nombre }} <input aria-label="Nota" /></li>
}
</ul>
</section>
</div>
`,
styles: `
.columnas { display: flex; gap: 24px; }
section { flex: 1; }
ul { padding: 0; list-style: none; }
li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }
input { width: 90px; }
.botones { display: flex; gap: 8px; }
`,
})
export class Track {
protected readonly filas = signal(iniciales);
protected recargar() {
this.filas.update((l) => l.map((f) => ({ ...f })));
}
protected invertir() {
this.filas.update((l) => [...l].reverse());
}
protected quitarPrimero() {
this.filas.update((l) => l.slice(1));
}
}
Con un MutationObserver contamos los <li> que se insertan en cada lista por cada acción:
| Acción | track f.id | track $index | track f |
|---|---|---|---|
| Recargar (mismos datos, objetos nuevos) | 0 | 0 | 4 |
| Invertir | 3 (movidos) | 0 | 3 (movidos) |
| Quitar el primero | 0 | 0 | 0 |
track fen la recarga inserta 4 filas: cada objeto nuevo es distinto para JavaScript, aunque tenga los mismos datos, así que Angular destruye la lista y la crea entera. Es lo que pasa cada vez que vuelven los datos de la API.track f.idno crea nada: la identidad es el número, que no cambia.track $indexparece el más rápido, pero tiene truco: al invertir no mueve nada, solo reescribe los textos de cada fila. Eso rompe cualquier estado que tenga el DOM, como se ve en la figura.
Hemos escrito una nota en la fila de Pedro cuando ocupaba el primer lugar, y luego hemos invertido la lista:

Figura 11. Con track $index la nota escrita para Pedro ha acabado en la fila de Ana.
Ese es el fallo real de $index: el nodo se queda en su posición con su <input>, y solo cambia el texto de al lado. Con formularios, foco o animaciones, los datos se cruzan.
| Expresión | Cuándo usarla |
|---|---|
track e.empId | Casi siempre: cualquier identificador único y estable |
track $index | Listas fijas que no se reordenan ni tienen estado |
track e | Listas de valores simples (cadenas, números), o de objetos que no se recrean |
Dos avisos de Angular
En desarrollo, Angular detecta los dos errores típicos:
| Código | Situación |
|---|---|
NG0956 | El track ha provocado que se recree toda la colección |
NG0955 | El track da claves duplicadas |
Al pulsar «Recargar» en nuestra columna track f, la consola mostró este aviso:
NG0956: The configured tracking expression (track by identity) caused re-creation of
the entire collection of size 4. This is an expensive operation requiring destruction
and subsequent creation of DOM nodes, directives, components etc.
Listas muy largas
Un buen track hace que actualizar sea barato, pero no que tener 10 000 filas lo sea. Si una lista es enorme, pagina (la API EMPRESA acepta pagina y tamano) o usa virtual scroll del CDK, que solo crea las filas visibles (módulo 20).
12. Otras ayudas en tiempo de ejecución
Hay otros hábitos que evitan trabajo inútil. Ya los has visto, pero conviene tenerlos juntos:
computedfrente a funciones en la plantilla. Una función como{{ calcularTotal() }}se ejecuta en cada comprobación de la vista; uncomputedguarda su resultado hasta que cambia una de sus dependencias (módulo 05).- Pipes puros. Por lo mismo: un pipe puro solo se recalcula si cambia su entrada (módulo 07).
- Señales con OnPush. Con señales, Angular comprueba solo los componentes afectados (módulo 09).
- Búsquedas con debounce. No lances una petición por cada tecla (módulo 15).
13. Presupuestos: que no vuelva a crecer
Reducir el paquete una vez es fácil. Lo difícil es que siga pequeño seis meses después, cuando alguien añada una librería de fechas. Los presupuestos (budgets) hacen que ng build avise o falle cuando se supera un tamaño. ng new ya genera estos en angular.json:
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
}
}
type | Qué mide |
|---|---|
initial | El paquete inicial |
bundle | Un trozo con nombre (requiere name) |
allScript | Todo el JavaScript |
anyScript | Cada fichero de JavaScript por separado |
all | Toda la salida |
any | Cada fichero por separado |
anyComponentStyle | Los estilos de cada componente |
Además de maximumWarning y maximumError, también existen minimumWarning, minimumError, warning y error, que se usan con baseline para medir respecto a un tamaño de referencia.
Los tamaños se comparan con el Raw size. Prueba a apretar el presupuesto de nuestro panel sin @defer a un máximo de 250 kB:
{ "type": "initial", "maximumWarning": "200kB", "maximumError": "250kB" }
[WARNING] bundle initial exceeded maximum budget. Budget 200.00 kB was not met by 57.63 kB with a total of 257.63 kB.
[ERROR] bundle initial exceeded maximum budget. Budget 250.00 kB was not met by 7.63 kB with a total of 257.63 kB.
Application bundle generation failed.
El proceso termina con error, así que en integración continua la rama no pasa. Si el paquete crece por una buena razón, se sube el presupuesto en una decisión consciente, y el cambio queda en el historial.
💡 Consejo: Pon el máximo un poco por encima del tamaño actual, no en los 500 kB por defecto. Un presupuesto holgado no detecta nada. Los avisos que nadie lee no sirven, así que usa también
maximumError.
14. Analizar qué ocupa el paquete
Si el paquete pesa demasiado, necesitas saber por qué. ng build puede generar un fichero con el detalle:
ng build --stats-json
Crea dist/<proyecto>/browser-stats.json, el metafile de esbuild, y tienes dos maneras de leerlo:
- El analizador web de esbuild. En esbuild.github.io/analyze cargas el fichero y ves el paquete como un mapa. Funciona en el navegador.
- Un script propio. El fichero es JSON normal, así que unas líneas de Node bastan para agrupar por paquete de npm:
// analizar.mjs. Uso: node analizar.mjs dist/lab-angular/browser-stats.json
import { readFileSync } from 'node:fs';
const stats = JSON.parse(readFileSync(process.argv[2], 'utf8'));
for (const [fichero, salida] of Object.entries(stats.outputs)) {
if (!fichero.endsWith('.js')) continue;
const paquetes = new Map();
for (const [entrada, { bytesInOutput }] of Object.entries(salida.inputs)) {
const m = entrada.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/);
const nombre = m ? m[1] : 'tu código';
paquetes.set(nombre, (paquetes.get(nombre) ?? 0) + bytesInOutput);
}
console.log(`\n${fichero}: ${(salida.bytes / 1024).toFixed(1)} kB`);
for (const [nombre, bytes] of [...paquetes].sort((a, b) => b[1] - a[1])) {
console.log(`${(bytes / 1024).toFixed(1).padStart(8)} kB ${nombre}`);
}
}
Esta es su salida para el panel con @defer:
chunk-DFNS52KH.js: 191.1 kB
130.2 kB @angular/core
25.7 kB @angular/common
20.6 kB rxjs
11.6 kB @angular/platform-browser
1.9 kB tslib
chunk-T2PHIITA.js: 68.6 kB
46.2 kB @angular/cdk
19.7 kB @angular/material
2.0 kB tu código
main-6LEJCYGG.js: 0.7 kB
0.6 kB tu código
Se ve de un vistazo que la tabla ha pasado entera al trozo diferido: @angular/cdk y @angular/material ya no están en el inicial. Sin @defer, las dos líneas aparecían dentro del único main. También se ve que tu código es la parte más pequeña: lo que pesa son las dependencias.
Qué buscar en el análisis:
| Hallazgo | Qué hacer |
|---|---|
| Una librería grande usada en un solo sitio | @defer, loadComponent o import() dinámico |
| Librería entera cuando usas una función | Importa solo esa función, o busca una alternativa más ligera |
| Un paquete en formato CommonJS | No se puede eliminar código no usado. Angular avisa al compilar; busca su versión ESM. allowedCommonJsDependencies solo oculta el aviso |
| Módulos de Material sin usar | Quita del array imports lo que no uses |
| Dos versiones del mismo paquete | Revisa npm ls <paquete> |
📘 Concepto: La eliminación de código no usado se llama tree shaking. El empaquetador solo la puede hacer con módulos ES, cuyos
importyexportse analizan sin ejecutar nada. Por esosignaloresourceson funciones sueltas que se importan una a una: lo que no importas no llega al paquete.
15. Aislar errores con @boundary 🆕 v22.2
🆕 Desde Angular 22.2.
@boundaryatrapa los errores de pintado de una parte de la plantilla. No existe en la LTS (Angular 21), donde daNG5002. Si usas la LTS: no hay nada equivalente; evita que la plantilla lance errores comprobandohasValue()antes de leer un recurso y registra lo demás en unErrorHandlerglobal.
⚠️ Importante:
@boundaryestá en developer preview en Angular 22.2. La guía oficial lo dice expresamente («@boundaryis in developer preview», angular.dev), y la misma frase está en la documentación de la rama 22.2.x. Funciona y compila, pero su sintaxis puede cambiar en versiones posteriores. Úsalo si puedes asumir revisar tu código al actualizar.
El problema: un error se lleva a los vecinos
¿Qué pasa si un componente lanza un error mientras se pinta? Este ejemplo tiene dos hijos, uno con un dato corrupto:
// src/app/barreras/rompe-en-render.ts
import { Component, computed, input } from '@angular/core';
@Component({
selector: 'app-rompe-en-render',
template: `<p>Total: {{ total() }}</p>`,
})
export class RompeEnRender {
readonly fallar = input(true);
protected readonly total = computed(() => {
if (this.fallar()) {
throw new TypeError('Dato corrupto en el total');
}
return 42;
});
}
// src/app/barreras/hermanos.ts
import { Component, signal } from '@angular/core';
import { RompeEnRender } from './rompe-en-render';
@Component({
selector: 'app-saludo',
template: `<p>{{ texto() }}</p>`,
})
export class Saludo {
protected readonly texto = signal('Hola, soy el componente de al lado');
}
@Component({
selector: 'app-hermanos-sin',
imports: [RompeEnRender, Saludo],
template: `
<h2>Sin barrera</h2>
<app-rompe-en-render />
<app-saludo />
`,
})
export class HermanosSin {}

Figura 12. app-saludo no tiene ningún fallo, y aun así se queda vacío.
Saludo no aparece. El motivo es que el error interrumpe la pasada de actualización de la vista: Angular se detiene en el componente que falla y no llega a actualizar a los hermanos que vienen detrás. El error acaba en el ErrorHandler, pero el usuario se encuentra con media página en blanco y sin explicación.
La solución: una barrera
@boundary es un bloque de control de flujo con una alternativa @error, en la línea de un try/catch de plantilla:
@Component({
selector: 'app-hermanos-con',
imports: [RompeEnRender, Saludo],
template: `
<h2>Con barrera</h2>
@boundary {
<app-rompe-en-render />
} @error {
<p>Este bloque no está disponible.</p>
}
<app-saludo />
`,
})
export class HermanosCon {}

Figura 13. El fallo queda contenido, y app-saludo se pinta con normalidad.
📘 Concepto: Una barrera de errores (error boundary) es una zona de la interfaz que contiene sus fallos: si algo revienta dentro, se muestra una alternativa y el resto de la aplicación sigue en pie. El changelog habla de «ErrorBoundary», pero no hay ninguna clase pública con ese nombre que importar: la herramienta es el bloque de plantilla.
No confundas los dos @error: el de @defer responde a un fallo al descargar, y el de @boundary, a un fallo al pintar.
Un caso real: fichas de empleados
Recuerda del módulo 13 que leer value() de un recurso en estado de error lanza una excepción. Si un componente no lo comprueba, un 404 de la API se convierte en un error de pintado:
// src/app/barreras/ficha-empleado.ts
import { httpResource } from '@angular/common/http';
import { Component, input } from '@angular/core';
import { API } from '../api';
import { Empleado } from '../modelos';
@Component({
selector: 'app-ficha-empleado',
template: `
@if (!datos.isLoading()) {
<strong>{{ datos.value()?.nombre }} {{ datos.value()?.apellidos }}</strong>
<span> ({{ datos.value()?.oficio }})</span>
} @else {
<span>Cargando ficha...</span>
}
`,
})
export class FichaEmpleado {
readonly id = input.required<number>();
protected readonly datos = httpResource<Empleado>(() => `${API}/empleados/${this.id()}`);
}
Con una barrera por ficha, una ficha rota no afecta a las demás:
// src/app/barreras/fichas.ts
import { HttpErrorResponse } from '@angular/common/http';
import { Component } from '@angular/core';
import { FichaEmpleado } from './ficha-empleado';
@Component({
selector: 'app-fichas',
imports: [FichaEmpleado],
template: `
<h2>Fichas de empleados</h2>
<div class="tarjetas">
@for (id of ids; track id) {
<div class="tarjeta">
@boundary {
<app-ficha-empleado [id]="id" />
} @error (let err; reset = $reset; when esNoEncontrado(err)) {
<span>No existe el empleado {{ id }}.</span>
<button type="button" (click)="reset()">Reintentar</button>
} @error {
<span>No se pudo mostrar la ficha: {{ $error.message }}</span>
<button type="button" (click)="$reset()">Reintentar</button>
}
</div>
}
</div>
`,
styles: `
.tarjetas { display: flex; gap: 12px; flex-wrap: wrap; }
.tarjeta { border: 1px solid #c5cae9; border-radius: 6px; padding: 12px; flex: 1 1 240px; }
`,
})
export class Fichas {
protected readonly ids = [100, 101, 999];
protected esNoEncontrado(error: unknown): boolean {
const causa = (error as { cause?: unknown }).cause;
return causa instanceof HttpErrorResponse && causa.status === 404;
}
}

Figura 14. El empleado 999 da 404 en la API. Su tarjeta muestra el aviso y las otras dos funcionan.
En el bloque @error hay dos variables implícitas:
| Variable | Qué es |
|---|---|
$error | El error atrapado |
$reset() | Destruye la alternativa y vuelve a intentar pintar el contenido original |
Como en el caso de @for, puedes darles otro nombre: let err y reset = $reset hacen que err sea el error y reset la función.
La cláusula when elige entre varias alternativas. Angular las comprueba en orden y usa la primera que encaja, así que los casos concretos van primero y un @error sin when cierra la lista. Aquí, el error que llega es un ResourceValueError cuyo mensaje es «Resource is currently in an error state (see Error.cause for details)…», y su cause es el HttpErrorResponse real con el status 404.
⚠️ Importante: Aun con la barrera, lo correcto en este ejemplo sería comprobar
hasValue()en la propia ficha (módulo 13). Un 404 es un resultado esperable, y debe tratarse como tal. La barrera es la red de seguridad para lo que no esperabas, no un sustituto del control de errores.
Reintentar
$reset() no adivina si el problema se ha resuelto: vuelve a crear el contenido, y si sigue fallando, regresa a @error.
<label><input type="checkbox" (change)="fallar.set(false)" /> Reparar datos</label>
@boundary {
<app-rompe-en-render [fallar]="fallar()" />
} @error {
<p>Error: {{ $error.message }}</p>
<button type="button" (click)="$reset()">Reintentar</button>
}
Secuencia real:
| Acción | Resultado |
|---|---|
Carga inicial, fallar es true | Error: Dato corrupto en el total |
| Pulsar «Reintentar» sin reparar | Sigue el error |
| Marcar «Reparar datos» | Sigue el error: la barrera no se recupera sola |
| Pulsar «Reintentar» | Total: 42 |
Qué atrapa y qué no
Comprobado con un componente distinto para cada caso:
| Dónde se lanza el error | ¿Lo atrapa @boundary? |
|---|---|
| Constructor de un componente | Sí |
ngOnInit | Sí |
Expresión de plantilla o computed leído en ella | Sí |
Lectura de value() de un recurso con error | Sí |
Manejador de un evento ((click)) | No: el componente sigue como estaba y el error va a handleError |
Es lógico: la barrera vigila la creación y la actualización de la vista. Un clic ocurre fuera de ese proceso, y su error sigue el camino normal hacia el ErrorHandler.
Barreras anidadas
Si la alternativa también falla, el error sube a la barrera exterior:
@boundary {
@boundary {
<app-rompe-en-render />
} @error {
<app-rompe-en-render />
}
} @error {
<p>Exterior: {{ $error.message }}</p>
}
El resultado real es Exterior: Dato corrupto en el total. Si no hay ninguna barrera más arriba, el error pasa a ser un error normal sin atrapar.
Figura 15. Cada error lo atrapa la barrera más cercana.
La limitación de <ng-content>
Una barrera alrededor de <ng-content />, dentro de un componente envoltorio, no atrapa los errores del contenido proyectado, porque ese contenido pertenece a la vista del padre (módulo 06). La barrera debe estar en la plantilla del padre:
<!-- No: dentro de app-envoltorio -->
@boundary {
<ng-content />
} @error {
<p>Algo ha fallado.</p>
}
<!-- Sí: en el padre, rodeando al envoltorio -->
@boundary {
<app-envoltorio>
<app-rompe-en-render />
</app-envoltorio>
} @error {
<p>Algo ha fallado.</p>
}
Con @defer
Los dos bloques se complementan. Comprobamos cada fallo por separado con esta plantilla:
@boundary {
@defer (on immediate) {
<app-rompe-en-render />
} @placeholder {
<p>Esperando</p>
} @error {
<p>defer: fallo de descarga</p>
}
} @error {
<p>boundary: {{ $error.message }}</p>
}
| Fallo | Bloque que responde |
|---|---|
| El componente diferido lanza un error al pintarse | El @error de @boundary |
| No se puede descargar el trozo | El @error de @defer |
Registrar los errores con onViewError 🆕 v22.2
🆕 Desde Angular 22.2.
ErrorHandlerañade el método opcionalonViewError(error, detalles), yErrorDetailsestá en developer preview. No existe en la LTS (Angular 21). Si usas la LTS: registra los errores enhandleError, que es por donde pasan todos allí.
Un error que atrapa la barrera no desaparece: debes enterarte de él. Tu ErrorHandler puede recibirlo en un método propio:
// src/app/manejador-errores.ts
import { ErrorDetails, ErrorHandler, Injectable } from '@angular/core';
@Injectable()
export class ManejadorErrores implements ErrorHandler {
handleError(error: unknown): void {
console.log('[handleError]', (error as Error).message);
}
onViewError(error: Error, detalles: ErrorDetails): void {
console.log(
'[onViewError]',
error.message,
detalles.declarationType.name,
detalles.boundary?.type.name,
);
}
}
// src/app/app.config.ts (fragmento)
{ provide: ErrorHandler, useClass: ManejadorErrores },
Esto escribió en la consola una página de pruebas, cuyo componente padre se llama Boundary. Las clases salen con un _ delante por la forma en que se compilan en desarrollo, y hemos acortado la URL de la cuarta línea:
[onViewError] Fallo en el constructor _Boundary _Boundary
[onViewError] Dato corrupto en el total _RompeEnRender _Boundary
[onViewError] Fallo en ngOnInit _Boundary _Boundary
[onViewError] Resource is currently in an error state (see Error.cause for details): Http failure response for .../api/empleados/999: 404 Not Found _FichaEmpleado _Boundary
[handleError] Fallo en un manejador de clic
Campo de ErrorDetails | Contenido |
|---|---|
declarationType | La clase donde está el error. Si falla al crear un hijo, es el padre, como se ve en las líneas 1 y 3 |
declarationInstance | La instancia de esa clase |
boundary | Datos de la barrera: type (el componente donde está) y reset() |
caughtBy | La función que interceptó el error, si la hay |
Con onViewError definido, los errores atrapados llegan solo ahí. Sin ese método, lo comprobamos también, llegan a handleError. En ambos casos tu sistema de registro se entera. Si tienes uno, como Sentry, enlaza los dos métodos.
Errores en componentes dinámicos 🆕 v22.2
🆕 Desde Angular 22.2.
createComponent,ViewContainerRef.createComponentycreateEmbeddedViewadmiten la opciónonError. No existe en la LTS (Angular 21). Si usas la LTS: envuelve la llamada contry/catchpara los errores de creación; para los de pintado no hay alternativa, y debes evitarlos en el propio componente.
Para los componentes creados por código (módulo 03) no hay plantilla donde poner @boundary. La opción onError cumple esa función:
// src/app/barreras/programatico.ts
import { Component, signal, viewChild, ViewContainerRef } from '@angular/core';
import { RompeEnRender } from './rompe-en-render';
@Component({
selector: 'app-programatico',
template: `
<button type="button" (click)="crear()">Crear dinámico</button>
<ng-container #hueco />
<p>{{ mensaje() }}</p>
`,
})
export class Programatico {
protected readonly mensaje = signal('');
private readonly hueco = viewChild.required('hueco', { read: ViewContainerRef });
protected crear() {
this.hueco().createComponent(RompeEnRender, {
onError: (error, detalles) => {
this.mensaje.set(`onError: ${error.message} en ${detalles.declarationType.name}`);
},
});
}
}
El resultado en la página es onError: Dato corrupto en el total en _RompeEnRender. Aquí detalles.boundary vale undefined, porque no interviene ninguna barrera.
Alternativa para la LTS
Sin @boundary, en Angular 21 toca evitar que la plantilla lance errores. Con recursos, la fórmula es comprobar el estado antes de leer. Este componente compila en 21.2 y, por cierto, también es la versión recomendada en 22:
import { httpResource } from '@angular/common/http';
import { Component } from '@angular/core';
interface Empleado {
nombre: string;
apellidos: string;
oficio: string;
}
@Component({
selector: 'app-ficha',
template: `
@if (datos.hasValue()) {
<strong>{{ datos.value().nombre }} {{ datos.value().apellidos }}</strong>
} @else if (datos.error()) {
<span>No se pudo cargar la ficha.</span>
<button type="button" (click)="datos.reload()">Reintentar</button>
} @else {
<span>Cargando ficha...</span>
}
`,
})
export class Ficha {
protected readonly datos = httpResource<Empleado>(() => `/api/empleados/100`);
}
16. Lista de comprobación
Antes de dar por buena una pantalla:
| Pregunta | Herramienta |
|---|---|
¿Qué mide el LCP y lleva priority? | Consola en desarrollo, Lighthouse |
| ¿Cuánto ocupa el paquete inicial? | Salida de ng build, presupuestos |
| ¿Qué es lo más pesado y se usa siempre? | --stats-json y análisis |
| ¿Aparece el trozo diferido en Lazy chunk files? | Salida de ng build |
¿Tiene cada @defer un @placeholder del tamaño correcto? | Panel Performance, CLS |
¿Tiene cada @defer su @error? | Revisión de código |
¿Usan todas las listas un track por identificador? | Avisos NG0955 y NG0956 |
¿Las imágenes usan ngSrc, width y height? | Avisos NG02952 a NG02955 |
17. Ejercicios
Ejercicio 1: Diferir una tabla
Tienes un panel que importa y pinta <app-tabla-empleados /> directamente. Haz que su código no esté en el paquete inicial y se descargue al entrar en pantalla, con un marcador sencillo. ¿Cómo compruebas que ha funcionado?
Ver solución
import { Component } from '@angular/core';
import { TablaEmpleados } from './tabla-empleados';
@Component({
selector: 'app-panel',
imports: [TablaEmpleados],
template: `
<h1>Panel de EMPRESA</h1>
<p>Bienvenida al panel. Aquí tienes el resumen del día.</p>
@defer (on viewport) {
<app-tabla-empleados />
} @placeholder {
<p>La tabla se cargará al llegar aquí.</p>
}
`,
})
export class Panel {}
El TypeScript no cambia. El @placeholder es obligatorio aquí, porque on viewport sin destino vigila el marcador (si falta, da NG8019).
Compruébalo con ng build: tabla-empleados debe aparecer en Lazy chunk files, y el Initial total debe bajar. En nuestro caso pasó de 257,63 kB a 196,44 kB.
Ejercicio 2: Elegir disparador
Elige el disparador más adecuado para cada caso y justifica la respuesta:
- Una sección de comentarios al final de un artículo.
- Un editor de texto enriquecido muy pesado que se abre al pulsar «Responder».
- Un gráfico en el centro de la pantalla inicial, imprescindible.
- Un panel de administración que solo ve quien tiene ese rol.
- Un chat de ayuda que debe aparecer en cuanto la página esté tranquila.
Ver solución
on viewport, y mejor conrootMarginoprefetch on idle: muchos lectores no llegan al final.on interaction(boton); prefetch on hover(boton): solo lo pagan quienes lo usan, y el ratón sobre el botón da margen para adelantarse.- Ninguno: no uses
@defer. Si es imprescindible y está en la primera pantalla, debe ir en el paquete inicial; diferirlo retrasa el LCP y provoca saltos. when esAdmin(). Recuerda que@deferno sustituye a la seguridad: el permiso se comprueba siempre en el servidor.on idle.
Ejercicio 3: Todas las fases
Haz un componente con un botón «Ver gráfica» que descargue GraficaSalarios al pulsarlo. Debe tener marcador, un aviso de descarga que no parpadee en redes rápidas, y un mensaje si falla la descarga. Reserva espacio para que no haya saltos.
Ver solución
Es el componente Estados de la sección 4:
import { Component } from '@angular/core';
import { GraficaSalarios } from './grafica-salarios';
@Component({
selector: 'app-estados',
imports: [GraficaSalarios],
template: `
<h2>Salarios por departamento</h2>
<button type="button" #boton>Ver gráfica</button>
<div class="zona" aria-live="polite">
@defer (on interaction(boton)) {
<app-grafica-salarios />
} @placeholder (minimum 300ms) {
<p class="aviso">Pulsa el botón para cargar la gráfica.</p>
} @loading (after 100ms; minimum 500ms) {
<p class="aviso cargando">Descargando la gráfica...</p>
} @error {
<p class="aviso error">No se pudo descargar la gráfica. Recarga la página.</p>
}
</div>
`,
styles: `
.zona { min-height: 260px; margin-top: 12px; }
.aviso { padding: 16px; background: #eceff1; border-radius: 6px; }
.cargando { background: #e8eaf6; }
.error { background: #ffebee; color: #b71c1c; }
`,
})
export class Estados {}
Las claves son after 100ms, que no muestra el aviso en descargas muy rápidas, y minimum 500ms, que evita que parpadee si llega a aparecer. Para probar el @error, bloquea el trozo en la pestaña Network de las DevTools antes de pulsar (Block request URL).
Ejercicio 4: Adelantar la descarga
Modifica el ejercicio anterior para que el código se descargue al pasar el ratón sobre el botón, pero la gráfica siga pintándose solo al pulsarlo. ¿Cómo compruebas el orden?
Ver solución
Solo cambia la línea del @defer:
@defer (on interaction(boton); prefetch on hover(boton)) {
<app-grafica-salarios />
}
Abre la pestaña Network y lanza ng serve --no-hmr. Al pasar el ratón aparece una petición de un fichero chunk-...js, sin que la gráfica aparezca. Al pulsar no sale ninguna petición de trozos, solo la de la API, y la gráfica se pinta.
Ejercicio 5: Eliminar el salto
Esta plantilla da un CLS de 0,1186. Corrígela sin cambiar el disparador:
@defer (on timer(600ms)) {
<app-grafica-salarios />
} @placeholder {
<p>Cargando...</p>
}
<p class="debajo">Este párrafo baja de golpe.</p>
Ver solución
Reserva el espacio de la gráfica en un contenedor:
<div class="reserva">
@defer (on timer(600ms)) {
<app-grafica-salarios />
} @placeholder {
<p>Cargando...</p>
}
</div>
<p class="debajo">Este párrafo ya no se mueve.</p>
.reserva { min-height: 280px; }
La gráfica de nuestro ejemplo tiene siete filas, así que 280 px bastan. Con esta corrección, el CLS medido pasó a 0,0000. Si la altura varía mucho, elige la más habitual: un salto pequeño es mejor que uno grande.
Ejercicio 6: La nota que cambia de dueño
Una lista de empleados con un campo de notas usa track $index. Al ordenar por salario, las notas se quedan en su posición y pasan a otra persona. Explica la causa y corrígelo.
Ver solución
@for (e of empleados(); track e.empId) {
<li>{{ e.nombre }} <input aria-label="Nota" /></li>
}
Con track $index, la identidad de cada fila es su posición. Tras ordenar, la fila 0 sigue siendo la fila 0: Angular conserva el nodo con su <input> y solo cambia el texto. Con track e.empId, la identidad es el empleado, y Angular mueve el nodo entero, nota incluida. Así lo muestra la figura 11.
No uses track e: al recargar desde la API, los objetos son nuevos y se recrearía la lista completa, perdiendo también las notas.
Ejercicio 7: El trozo que no aparece
Este panel usa @defer, pero ng build no muestra ningún trozo diferido. Encuentra la causa y corrígela:
@Component({
selector: 'app-panel',
imports: [TablaEmpleados],
template: `
<h1>Panel de EMPRESA</h1>
@defer (on viewport) {
<app-tabla-empleados />
} @placeholder {
<p>La tabla se cargará al llegar aquí.</p>
}
`,
})
export class Panel {
protected readonly tabla = viewChild(TablaEmpleados);
}
Ver solución
viewChild(TablaEmpleados) usa la clase como valor en el TypeScript del componente, fuera del bloque. El compilador necesita entonces la clase al arrancar, y no puede convertir el import en dinámico. Esta versión ocupó 266,30 kB, más que sin @defer.
La solución es quitar la consulta. Si necesitas comunicarte con la tabla, hazlo con input() y output() dentro del bloque, sin referencia a la clase:
import { Component } from '@angular/core';
import { TablaEmpleados } from './tabla-empleados';
@Component({
selector: 'app-panel',
imports: [TablaEmpleados],
template: `
<h1>Panel de EMPRESA</h1>
@defer (on viewport) {
<app-tabla-empleados />
} @placeholder {
<p>La tabla se cargará al llegar aquí.</p>
}
`,
})
export class Panel {}
Ejercicio 8: Probar los estados
Escribe una prueba que compruebe que el componente del ejercicio 3 muestra el aviso de error cuando falla la descarga, sin provocar un fallo de red real.
Ver solución
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { Estados } from './estados';
describe('Estados', () => {
it('muestra el aviso cuando falla la descarga', async () => {
TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Manual,
providers: [provideHttpClient(), provideHttpClientTesting()],
});
const fixture = TestBed.createComponent(Estados);
await fixture.whenStable();
const [bloque] = await fixture.getDeferBlocks();
await bloque.render(DeferBlockState.Error);
expect(fixture.nativeElement.textContent).toContain('No se pudo descargar');
});
});
DeferBlockBehavior.Manual detiene el bloque en su marcador, y render lo lleva directamente al estado que quieres.
Ejercicio 9: Una barrera por tarjeta 🆕 v22.2
🆕 Desde Angular 22.2. El ejercicio usa
@boundary, en developer preview, que no existe en la LTS (Angular 21). Si usas la LTS: resuélvelo conhasValue()yerror()en el propio componente, como en la alternativa de la sección 15.
Muestra tres fichas con los ids 100, 101 y 999. Si una falla, solo esa tarjeta debe mostrar un aviso. Para el 404 usa un mensaje propio, y para cualquier otro error, el mensaje del error y un botón para reintentar.
Ver solución
Es el componente Fichas de la sección 15. La parte esencial es que la barrera va dentro del @for:
@for (id of ids; track id) {
<div class="tarjeta">
@boundary {
<app-ficha-empleado [id]="id" />
} @error (let err; reset = $reset; when esNoEncontrado(err)) {
<span>No existe el empleado {{ id }}.</span>
<button type="button" (click)="reset()">Reintentar</button>
} @error {
<span>No se pudo mostrar la ficha: {{ $error.message }}</span>
<button type="button" (click)="$reset()">Reintentar</button>
}
</div>
}
protected esNoEncontrado(error: unknown): boolean {
const causa = (error as { cause?: unknown }).cause;
return causa instanceof HttpErrorResponse && causa.status === 404;
}
Si pusieras la barrera fuera del @for, un solo error sustituiría las tres tarjetas.
Ejercicio 10 (propuesto): Presupuesto a medida
Compila tu proyecto, anota el Initial total y ajusta maximumWarning y maximumError a un 5 % y un 10 % por encima de esa cifra. Añade después una dependencia grande a un componente inicial, y comprueba que ng build avisa o falla. Por último, difiérela con @defer y verifica que el presupuesto vuelve a cumplirse.
Ejercicio 11 (propuesto): Auditoría de imágenes
Convierte a NgOptimizedImage todas las imágenes de una página de tu aplicación. Marca con priority solo la imagen que marque el LCP, y corrige cada aviso que aparezca en la consola. Compara el LCP de Lighthouse antes y después.
Ejercicio 12 (propuesto): Análisis del proyecto
Genera browser-stats.json de la aplicación EMPRESA y usa el script de la sección 14 o el analizador de esbuild. Haz una lista de las tres dependencias más pesadas, indica si están en el paquete inicial y decide en cada caso si conviene diferirla.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Métricas | LCP, INP, CLS | Objetivos: 2,5 s, 200 ms y 0,1 en el percentil 75 |
| Salida de compilación | ng build | Raw size se ejecuta; transfer size se descarga |
| Vista diferible | @defer | El compilador crea un trozo aparte con lo exclusivo del bloque |
| Estados | @placeholder, @loading, @error | Solo avanzan; los bloques auxiliares van en el paquete inicial |
| Evitar parpadeos | minimum, after | Tiempo mínimo visible y retraso del aviso |
| Disparadores | on idle, viewport, interaction, hover, immediate, timer, when | Varios se combinan con ; y funcionan como un O |
Tiempo máximo de idle | on idle(1500) 🆕 v22.0 | En la LTS: on idle; on timer(1500ms) |
| Adelantar descarga | prefetch on ... | Descarga antes, pinta con el disparador principal |
Falsos @defer | Salida de ng build | Cualquier uso fuera del bloque, incluido viewChild, lo anula sin aviso |
| Desarrollo | ng serve --no-hmr | Con HMR, los disparadores no se comportan igual |
| Saltos de diseño | min-height en el contenedor | CLS de 0,1186 a 0 con una línea de CSS |
| Pruebas | DeferBlockBehavior.Manual, getDeferBlocks, render | Cada estado sin red ni esperas |
| Imágenes | NgOptimizedImage, ngSrc, priority, fill | Medidas obligatorias, lazy por defecto y avisos en desarrollo |
| Listas | track e.empId | Identificador estable; $index mezcla estado y track e recrea filas |
| Presupuestos | budgets en angular.json | Hacen fallar la compilación si el paquete crece |
| Análisis | ng build --stats-json | browser-stats.json y analizador de esbuild |
| Barrera de errores | @boundary, @error, $error, $reset, when 🆕 v22.2 | Developer preview. Atrapa errores de pintado, no de eventos |
| Registro de errores | ErrorHandler.onViewError, ErrorDetails 🆕 v22.2 | Los errores atrapados también deben registrarse |
| Componentes dinámicos | Opción onError 🆕 v22.2 | El equivalente a @boundary para código |