Deferrable Views en Angular: carga lo que necesitas, cuando lo necesitas
Kevin Dávila
Cuando Angular compila tu aplicación, todo lo que importas en un componente acaba en el bundle. No importa si ese componente se renderiza en la primera pantalla o si el usuario tiene que bajar cinco veces para verlo — el código llega junto al resto.
@defer cambia eso. Es un bloque de plantilla que le dice al compilador que el código de ciertos componentes se cargue en un chunk separado, y que ese chunk solo se descargue cuando se cumpla una condición concreta.
Llegó como developer preview en Angular 17 y se estabilizó en v18. — Source: Deferred loading with @defer • Angular
El problema sin @defer
Imagina una página de reporte de ventas. Tiene un encabezado, algunos KPIs arriba del fold, y después, más abajo: una gráfica de Chart.js, una tabla con datos históricos, y un widget de soporte.
Sin @defer:
// sales-report.component.ts
import { SalesChartComponent } from './sales-chart.component'; // Chart.js: ~200kb
import { DataTableComponent } from './data-table.component'; // ag-Grid: ~400kb
import { SupportChatComponent } from './support-chat.component'; // Intercom: ~120kb
@Component({
imports: [SalesChartComponent, DataTableComponent, SupportChatComponent],
template: `
<app-kpi-header />
<app-sales-chart />
<app-data-table />
<app-support-chat />
`
})
export class SalesReportComponent {}
Los 720kb de esas tres librerías se descargan aunque el usuario nunca haga scroll hasta la gráfica, nunca abra el chat, y nunca cargue la tabla. Está ahí, en el bundle inicial, siempre.
Con @defer, cada uno se convierte en un chunk independiente que solo llega cuando lo activa un trigger.
Anatomía de un bloque @defer
<!-- sales-report.component.html -->
@defer (on viewport) {
<app-sales-chart />
}
@placeholder {
<div class="chart-skeleton">Cargando gráfica...</div>
}
@loading (after 300ms; minimum 1s) {
<app-spinner />
}
@error {
<p>No se pudo cargar la gráfica.</p>
}
Cuatro bloques, cada uno con un rol distinto:
@defer — el contenido que se quiere diferir. El código de los componentes dentro de este bloque no se incluye en el bundle inicial. Se descarga como un chunk separado cuando el trigger se activa.
@placeholder — lo que se muestra antes de que el trigger se active. El usuario ve esto mientras el bloque @defer aún no ha empezado a cargar. Si no pones un @placeholder, hay espacio vacío. Sus dependencias sí se cargan de forma eager (están en el bundle inicial).
@loading — lo que se muestra mientras el chunk está descargándose. Reemplaza al @placeholder en ese momento. El parámetro after 300ms evita que aparezca si la carga tarda menos de 300ms — así no hay un spinner que aparece y desaparece en un parpadeo. El parámetro minimum 1s garantiza que, si aparece, se queda al menos ese tiempo para evitar flicker.
@error — lo que se muestra si la descarga falla. Sin este bloque, un fallo de red simplemente no renderiza nada.
Todos los triggers
on idle — cuando el navegador está libre
<!-- sales-report.component.html -->
@defer (on idle) {
<app-data-table [rows]="salesData" />
}
@placeholder {
<app-table-skeleton />
}
El navegador espera a que no tenga trabajo urgente (usa requestIdleCallback internamente). Es el trigger por defecto si no especificas ninguno. Bueno para cosas que no son críticas para la interacción inicial pero que el usuario probablemente necesite pronto.
on viewport — cuando el elemento entra en pantalla
<!-- sales-report.component.html -->
@defer (on viewport) {
<app-sales-chart [data]="chartData" />
}
@placeholder {
<div class="chart-placeholder">Desplázate para ver la gráfica</div>
}
Usa IntersectionObserver para detectar cuándo el placeholder entra en el viewport. Cuando ocurre, descarga el chunk y renderiza. Es el trigger más obvio para contenido below the fold.
on interaction — cuando el usuario actúa sobre el placeholder
<!-- sales-report.component.html -->
@defer (on interaction) {
<app-export-modal [reportId]="reportId" />
}
@placeholder {
<button class="export-btn">Exportar reporte</button>
}
Se activa con click o keydown sobre el placeholder. El usuario ve un botón normal. Al hacer click, el chunk se descarga y el modal aparece. El modal pesado y sus dependencias no llegan hasta que se necesitan.
on hover — cuando el cursor entra en la zona
<!-- sales-report.component.html -->
@defer (on hover) {
<app-support-chat [userId]="currentUser.id" />
}
@placeholder {
<button class="chat-btn">Soporte</button>
}
Se activa con mouseover o focusin. El widget de chat y su SDK de terceros solo llegan cuando el usuario muestra intención de usarlo.
on timer — después de un tiempo fijo
<!-- sales-report.component.html -->
@defer (on timer(5s)) {
<app-upsell-banner />
}
Carga el chunk después del tiempo indicado. Útil para contenido secundario que no debe competir con la carga inicial pero tampoco necesita una condición de usuario.
on immediate — tan pronto como Angular puede
<!-- sales-report.component.html -->
@defer (on immediate) {
<app-notification-panel />
}
Dispara la descarga inmediatamente, pero en un chunk separado. El contenido llega un momento después del render inicial, no bloquea el bundle principal. No muestra el @placeholder — el bloque aparece directamente cuando carga.
when — cuando una condición es verdadera
<!-- sales-report.component.html -->
@defer (when showFilters) {
<app-advanced-filters (apply)="applyFilters($event)" />
}
@placeholder {
<button (click)="showFilters = true">Filtros avanzados</button>
}
// sales-report.component.ts
@Component({ ... })
export class SalesReportComponent {
showFilters = false;
}
Cuando showFilters pasa a true, el chunk se descarga y se renderiza. Importante: es una transición de una sola vez. Si showFilters vuelve a false, el bloque no regresa al estado de placeholder — ya está cargado.
Combinando triggers
Se pueden combinar con punto y coma. Se evalúan como OR — el primero en cumplirse gana:
<!-- sales-report.component.html -->
@defer (on viewport; on timer(3s)) {
<app-sales-chart />
}
Carga cuando entra en pantalla, o a los 3 segundos si el usuario no ha hecho scroll. Útil para garantizar que el contenido siempre carga aunque el usuario no interactúe.
Prefetch: descarga en segundo plano, renderiza después
El prefetch separa dos cosas: cuándo se descarga el JavaScript y cuándo se renderiza el componente. Se define en el mismo bloque:
<!-- sales-report.component.html -->
@defer (on interaction; prefetch on idle) {
<app-export-modal [reportId]="reportId" />
}
@placeholder {
<button>Exportar</button>
}
En este ejemplo: el JavaScript del modal se descarga mientras el navegador está idle (en segundo plano, sin que el usuario note nada). Cuando el usuario hace click en "Exportar", el chunk ya está en caché y el modal aparece de inmediato, sin espera de red.
<!-- sales-report.component.html -->
@defer (on hover; prefetch on viewport) {
<app-support-chat />
}
@placeholder {
<button>Soporte</button>
}
Aquí: el SDK del chat se prefetchea cuando el botón entra en pantalla. Cuando el usuario hace hover, ya está listo.
El prefetch es especialmente útil cuando el trigger de renderizado (interaction, hover) es rápido y el usuario esperaría si hubiera latencia de red.
Un ejemplo realista completo
Una página de reporte con varias secciones, cada una con la estrategia que le corresponde:
<!-- sales-report.component.html -->
<!-- Encabezado: crítico, siempre eager -->
<app-kpi-header [period]="period" />
<!-- Gráfica principal: below the fold, prefetch en idle -->
@defer (on viewport; prefetch on idle) {
<app-sales-chart [data]="chartData" [period]="period" />
} @placeholder {
<div class="chart-placeholder">
<app-chart-skeleton />
</div>
} @loading (after 200ms; minimum 500ms) {
<app-spinner label="Cargando gráfica..." />
} @error {
<app-error-card message="No se pudo cargar la gráfica" />
}
<!-- Tabla de datos: pesada, carga cuando el browser esté libre -->
@defer (on idle) {
<app-data-table
[rows]="salesData"
[columns]="tableColumns"
(rowClick)="openDetail($event)"
/>
} @placeholder {
<app-table-skeleton [rows]="10" />
} @loading (after 300ms) {
<app-spinner label="Preparando tabla..." />
}
<!-- Filtros avanzados: solo si el usuario los pide -->
@defer (when showFilters) {
<app-advanced-filters
[current]="activeFilters"
(apply)="applyFilters($event)"
/>
} @placeholder {
<button class="filters-btn" (click)="showFilters = true">
Filtros avanzados
</button>
}
<!-- Modal de exportación: carga en background, renderiza al click -->
@defer (on interaction; prefetch on idle) {
<app-export-modal
[reportId]="reportId"
(close)="closeExport()"
/>
} @placeholder {
<button class="export-btn">Exportar reporte</button>
} @error {
<p>Error al cargar exportación</p>
}
<!-- Widget de soporte: carga al hover, prefetch en viewport -->
@defer (on hover; prefetch on viewport) {
<app-support-chat [userId]="currentUser.id" />
} @placeholder {
<button class="support-btn" aria-label="Abrir chat de soporte">
Soporte
</button>
}
En el bundle inicial solo llega lo que está fuera de los bloques @defer — el encabezado y las dependencias de los @placeholder. Lo demás llega en chunks separados, bajo demanda.
@defer con SSR e hidratación incremental
En el blog anterior sobre rendering en Angular vimos que con SSR el servidor genera el HTML completo y el cliente lo hidrata. Con @defer y la hidratación incremental activada, el HTML prerenderizado incluye el contenido de los bloques @defer, pero el JavaScript para cada sección solo se descarga cuando es necesario.
// app.config.ts
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withIncrementalHydration()
),
]
};
Con esto activo, los bloques @defer en plantillas aceptan triggers de hidratación con el prefijo hydrate:
<!-- sales-report.component.html -->
<!-- HTML llega prerenderizado, hidratación espera al viewport -->
@defer (hydrate on viewport) {
<app-sales-chart [data]="chartData" />
} @placeholder {
<app-chart-skeleton />
}
<!-- Comentarios: HTML estático hasta que el usuario interactúa -->
@defer (hydrate on interaction) {
<app-comments [reportId]="reportId" />
} @placeholder {
<button>Ver comentarios</button>
}
<!-- Sección estática que nunca necesita hidratarse -->
@defer (hydrate never) {
<app-static-disclaimer />
}
El servidor renderiza todo. El cliente solo descarga el JavaScript de cada sección cuando la condición de hydrate se cumple. Esto reduce significativamente el JavaScript que se ejecuta al cargar la página.
Source: Incremental Hydration • Angular
Lo que no puedes diferir
No todo componente dentro de un @defer va a separarse en un chunk. Hay condiciones:
Solo se pueden diferir componentes standalone. Si tienes un componente declarado en un NgModule, Angular lo incluye en el bundle principal aunque esté dentro de un @defer. No hay error — simplemente no se difiere.
// Este componente NO se puede diferir
@NgModule({ declarations: [OldChartComponent] })
export class ChartsModule {}
<!-- OldChartComponent acaba en el bundle principal igualmente -->
@defer (on viewport) {
<old-chart-component />
}
Si usas el componente fuera del bloque @defer en el mismo archivo, tampoco se difiere. Por ejemplo, si tienes un @ViewChild apuntando a ese componente, Angular lo marca como eager.
// sales-report.component.ts
@Component({ ... })
export class SalesReportComponent {
// Esta referencia impide que SalesChartComponent se difiera
@ViewChild(SalesChartComponent) chart!: SalesChartComponent;
}
Los sub-bloques (@placeholder, @loading, @error) y sus dependencias siempre son eager. Tienen que estar disponibles antes de que el chunk se descargue, por definición. Evita poner componentes pesados en ellos.
Testing
Angular proporciona DeferBlockFixture para controlar el estado de los bloques @defer en tests sin depender de timers reales o eventos del DOM.
// sales-report.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { DeferBlockBehavior, DeferBlockState } from '@angular/core/testing';
import { SalesReportComponent } from './sales-report.component';
describe('SalesReportComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SalesReportComponent],
}).compileComponents();
});
it('muestra el skeleton antes de que el defer cargue', async () => {
const fixture = TestBed.createComponent(SalesReportComponent);
fixture.detectChanges();
const [chartDefer] = await fixture.getDeferBlocks();
await chartDefer.render(DeferBlockState.Placeholder);
expect(fixture.nativeElement.querySelector('app-chart-skeleton')).toBeTruthy();
});
it('muestra el spinner durante la carga', async () => {
const fixture = TestBed.createComponent(SalesReportComponent);
fixture.detectChanges();
const [chartDefer] = await fixture.getDeferBlocks();
await chartDefer.render(DeferBlockState.Loading);
expect(fixture.nativeElement.querySelector('app-spinner')).toBeTruthy();
});
it('renderiza la gráfica cuando el defer completa', async () => {
const fixture = TestBed.createComponent(SalesReportComponent);
fixture.detectChanges();
const [chartDefer] = await fixture.getDeferBlocks();
await chartDefer.render(DeferBlockState.Completed);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-sales-chart')).toBeTruthy();
});
it('muestra el error si la carga falla', async () => {
const fixture = TestBed.createComponent(SalesReportComponent);
fixture.detectChanges();
const [chartDefer] = await fixture.getDeferBlocks();
await chartDefer.render(DeferBlockState.Error);
expect(fixture.nativeElement.querySelector('app-error-card')).toBeTruthy();
});
});
DeferBlockState tiene cuatro valores: Placeholder, Loading, Completed y Error. Los tests no necesitan simular scroll ni timers — controlas el estado directamente.
Source: DeferBlockFixture • Angular
Resumen de triggers
Trigger | Se activa cuando | Caso de uso típico |
|---|---|---|
| El navegador no tiene trabajo urgente | Tablas de datos, contenido secundario |
| El placeholder entra en pantalla | Gráficas below the fold, imágenes pesadas |
| Click o keydown sobre el placeholder | Modales, paneles que abren al click |
| Cursor sobre el placeholder | Tooltips, widgets de terceros |
| Inmediatamente, en chunk separado | Contenido casi-crítico sin bloquear bundle |
| Después de N segundos | Banners, anuncios secundarios |
| Una variable llega a | Secciones condicionadas por lógica |
| En segundo plano antes del trigger | Cualquier caso donde haya latencia de red |
Keywords
Angular @defer deferrable views
lazy loading components Angular
on viewport on idle Angular trigger
Angular @placeholder @loading
defer block Angular 18
Meta-description
Guía práctica de Deferrable Views en Angular: qué es @defer, todos los triggers con ejemplos realistas, prefetch, integración con SSR y cómo hacer testing de bloques defer.