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, @loading y @error, y evitar parpadeos con minimum y after
  • Elegir disparador: idle, viewport, interaction, hover, immediate, timer y when
  • Adelantar la descarga con prefetch sin 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 @defer con DeferBlockBehavior.Manual
  • Optimizar imágenes con NgOptimizedImage
  • Elegir bien el track de 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.

Dos barras: sin defer todo va en el paquete inicial; con defer, la tabla y sus dependencias pasan a un trozo aparte unido por un import dinámico

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:

  1. El compilador ve que TablaEmpleados solo se usa dentro del bloque @defer.
  2. Elimina el import estático de ese componente del código generado y lo cambia por un import() dinámico.
  3. esbuild, el empaquetador de ng build, ve ese import() y crea un trozo aparte.
  4. 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: @defer frente a loadComponent. Las dos crean trozos separados, pero a distinta escala. loadComponent divide por páginas: el trozo se pide al navegar. @defer divide 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:

Diagrama de estados: placeholder pasa a loading con el disparador, y loading pasa al contenido principal si la descarga va bien o a error si falla

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:

Pantalla con el botón Ver gráfica y el aviso Pulsa el botón para cargar la gráfica

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

Pantalla con el aviso Descargando la gráfica

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

Gráfica de barras con el salario medio de cada departamento

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

Pantalla con un aviso rojo que dice que no se pudo descargar la gráfica

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 tu ErrorHandler llega el error NG0750: 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:

Lista de disparadores: idle, idle con tiempo, immediate y timer ya cargados, y los que dependen de interacción aún esperando

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 @if controla la presencia y el @defer la 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 da NG5002: "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. provideIdleServiceWith sustituye 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 idle usa siempre requestIdleCallback. Para otro criterio, usa when con 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 {}

Dos líneas de tiempo: sin prefetch la descarga empieza con el clic; con prefetch empieza al pasar el ratón y el clic solo pinta

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 aviso NG8021 al compilar. En la LTS (Angular 21) compila sin avisos. Si usas la LTS: escribe siempre un on ... junto al prefetch, 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:

  1. Debe ser standalone. Lo que se declara en un NgModule se carga siempre al principio.
  2. 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.ts que 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 MatTableModule tambié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.

Página final sin reservar espacio: la gráfica ha empujado a los párrafos hacia abajo

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

Página final con el espacio reservado para la gráfica y los párrafos en su sitio

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, timer ni viewport en 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 viewport con rootMargin es 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 interaction es 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 Error y luego pedía Complete, y el contenido nunca aparecía: Error es 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: NgOptimizedImage no 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 admite background-image (usa fill) 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 f en 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.id no crea nada: la identidad es el número, que no cambia.
  • track $index parece 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:

Tres listas tras invertir: en track f.id y track f la nota sigue junto a Pedro; en track $index se ha quedado junto a Ana

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:

  • computed frente a funciones en la plantilla. Una función como {{ calcularTotal() }} se ejecuta en cada comprobación de la vista; un computed guarda 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 import y export se analizan sin ejecutar nada. Por eso signal o resource son 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. @boundary atrapa los errores de pintado de una parte de la plantilla. No existe en la LTS (Angular 21), donde da NG5002. Si usas la LTS: no hay nada equivalente; evita que la plantilla lance errores comprobando hasValue() antes de leer un recurso y registra lo demás en un ErrorHandler global.

⚠️ Importante: @boundary está en developer preview en Angular 22.2. La guía oficial lo dice expresamente («@boundary is 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 {}

Página con solo el título Sin barrera; ninguno de los dos componentes ha pintado su contenido

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 {}

Página con el título Con barrera, el aviso de bloque no disponible y debajo el saludo del componente vecino

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;
  }
}

Tres tarjetas: Ana García López, Luis Martínez Ruiz y un aviso de que no existe el empleado 999 con un botón Reintentar

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.

Dos barreras anidadas: el error del componente se muestra en el error interior, y si ese falla sube al exterior; el resto de la página sigue activa

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. ErrorHandler añade el método opcional onViewError(error, detalles), y ErrorDetails está en developer preview. No existe en la LTS (Angular 21). Si usas la LTS: registra los errores en handleError, 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.createComponent y createEmbeddedView admiten la opción onError. No existe en la LTS (Angular 21). Si usas la LTS: envuelve la llamada con try/catch para 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:

  1. Una sección de comentarios al final de un artículo.
  2. Un editor de texto enriquecido muy pesado que se abre al pulsar «Responder».
  3. Un gráfico en el centro de la pantalla inicial, imprescindible.
  4. Un panel de administración que solo ve quien tiene ese rol.
  5. Un chat de ayuda que debe aparecer en cuanto la página esté tranquila.
Ver solución
  1. on viewport, y mejor con rootMargin o prefetch on idle: muchos lectores no llegan al final.
  2. 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.
  3. 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.
  4. when esAdmin(). Recuerda que @defer no sustituye a la seguridad: el permiso se comprueba siempre en el servidor.
  5. 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 con hasValue() y error() 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