Carga inicial y Core Web Vitals
Carga inicial y Core Web Vitals
En el capítulo 6 vimos cómo hacer que la app sea rápida una vez que está corriendo: change detection, OnPush, zoneless. Este capítulo ataca el otro frente, el que el usuario ve primero y el que Google mide: la carga inicial. De nada sirve un runtime impecable si el usuario mira una pantalla en blanco durante tres segundos mientras se descarga un bundle de 2 MB.
El foco acá es doble: entender qué miden los Core Web Vitals y cómo cada decisión de Angular (routing, @defer, SSR, imágenes, dependencias) mueve esas agujas. No es maquillaje: cada milisegundo de LCP y cada kilobyte de JavaScript inicial tiene una causa concreta en tu código y un mecanismo concreto para reducirlo.
Los tres Core Web Vitals que importan
Los Core Web Vitals son el subconjunto de métricas que Google usa como señal de experiencia. Se miden en el percentil 75 de las cargas reales (field data), segmentando móvil y escritorio. El umbral “bueno” no es una recomendación blanda: es el número que separa un Good de un Needs improvement en Search Console.
| Métrica | Qué mide | Umbral “bueno” | Palanca principal en Angular |
|---|---|---|---|
| LCP (Largest Contentful Paint) | Carga: cuándo se pinta el elemento más grande visible | ≤ 2.5 s | Tamaño del bundle inicial, SSR, imágenes |
| INP (Interaction to Next Paint) | Interactividad: latencia de la peor interacción | ≤ 200 ms | JS en el hilo principal, hydration, OnPush/zoneless |
| CLS (Cumulative Layout Shift) | Estabilidad visual: saltos de layout | ≤ 0.1 | Dimensiones reservadas de imágenes, placeholders |
INP reemplazó a FID en marzo de 2024. FID solo medía la latencia del primer input; INP mide la latencia de todas las interacciones y reporta (aproximadamente) la peor. Esto castiga mucho más al JavaScript pesado en el hilo principal, que es exactamente lo que una SPA de Angular puede acumular si no lo controlás.
Cómo Angular impacta cada métrica
- LCP lo domina cuánto tarda en descargarse, parsearse y ejecutarse el JavaScript necesario para pintar el contenido principal, más el tiempo de la imagen/hero si el elemento más grande es una imagen. Un bundle inicial gordo retrasa el bootstrap de Angular y con él, el LCP. SSR ayuda porque el HTML llega ya pintado.
- INP lo domina el trabajo en el hilo principal. Cada interacción que dispara change detection, hydration de un bloque, o recalcula un
computedcaro, suma latencia.OnPush, zoneless y hydration incremental atacan esto directamente. - CLS lo domina el contenido que aparece después del primer pintado y empuja lo que ya estaba: imágenes sin
width/height, fuentes que causan reflow, y@defermal configurados cuyo placeholder tiene distinto tamaño que el contenido final.
flowchart LR
subgraph Red["Descarga"]
HTML[HTML SSR] --> CSS[CSS crítico]
CSS --> JS[Bundle inicial JS]
end
subgraph Main["Hilo principal"]
JS --> Boot[Bootstrap Angular]
Boot --> Hydr[Hydration]
Hydr --> Idle[Idle: hidrata @defer diferidos]
end
HTML -.pinta contenido.-> LCP{{LCP}}
Hydr -.listo para responder.-> INP{{INP}}
CSS -.reserva espacio.-> CLS{{CLS}}
style LCP fill:#1a7f37,color:#fff
style INP fill:#0969da,color:#fff
style CLS fill:#9a6700,color:#fff
Lazy loading de rutas: no cargues lo que no se ve
La palanca más grande sobre el bundle inicial es no meter en él código que el usuario todavía no necesita. Si tu ruta /admin viaja en el mismo chunk que la home, todos pagan el peso del panel de administración aunque nunca entren.
Angular hace code splitting automático por cada loadComponent/loadChildren en el router. Cada uno genera un chunk separado que se descarga on demand.
Antipatrón: rutas eager, todo en el bundle inicial.
// app.routes.ts — todo se empaqueta junto
import { DashboardComponent } from './dashboard/dashboard.component';
import { AdminPanelComponent } from './admin/admin-panel.component';
import { ReportsComponent } from './reports/reports.component';
export const routes: Routes = [
{ path: 'dashboard', component: DashboardComponent },
{ path: 'admin', component: AdminPanelComponent }, // pesa aunque nunca se abra
{ path: 'reports', component: ReportsComponent }, // idem
];
Acá el import estático arrastra los tres componentes (y sus dependencias transitivas: gráficos, tablas, PDF) al chunk inicial. El usuario que solo mira el dashboard descarga y parsea el panel de admin y el motor de reportes sin usarlos.
Mejor: loadComponent por ruta.
// app.routes.ts — cada ruta es su propio chunk
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component').then((m) => m.DashboardComponent),
},
{
path: 'admin',
// Un feature entero con sub-rutas: loadChildren
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
{
path: 'reports',
loadComponent: () => import('./reports/reports.component'),
// atajo válido si el componente es el `default export`
},
];
El import() dinámico es lo que le dice al bundler (esbuild en Angular 22) que corte ahí. Cada ruta se convierte en un chunk que solo se descarga al navegar. El bundle inicial baja al esqueleto de la app más la primera ruta.
Con standalone por defecto (Angular 22) ya no existen los
NgModulede feature:loadChildrenapunta a un array deRoutesexportado, no a un módulo. Es menos ceremonia y el mismo code splitting.
Precarga con criterio. Que sea lazy no significa que el usuario tenga que esperar la descarga al hacer clic. Podés precargar en segundo plano una vez que la app está ociosa:
// app.config.ts
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules)),
],
};
PreloadAllModules descarga todos los chunks lazy después del bootstrap, sin bloquear la carga inicial: bajás el bundle inicial y eliminás la espera al navegar. Para apps grandes, una estrategia de precarga selectiva (solo las rutas probables) evita descargar chunks que casi nadie visita.
@defer: diferir componentes dentro de una vista
loadComponent corta por ruta. @defer (deferrable views) corta dentro de una plantilla: te deja diferir la carga de un pedazo de UI —un gráfico pesado, un chat, un carrusel— hasta que se cumpla un trigger. El código de ese bloque y sus dependencias salen del bundle de la ruta y viajan en su propio chunk.
La regla de oro: lo que está abajo del pliegue (below the fold) o detrás de una interacción no debería costar JavaScript en la carga inicial.
Triggers disponibles
@defer (on idle) { // por defecto: cuando el navegador está ocioso
<heavy-chart [data]="data()" />
}
@defer (on viewport) { // cuando el bloque entra al viewport
<comments-section />
} @placeholder {
<div class="skeleton">Cargando comentarios…</div>
}
@defer (on interaction) { // al hacer click/keydown sobre el placeholder
<rich-text-editor />
} @placeholder {
<button>Escribir respuesta</button>
}
@defer (on hover) { // mouseover / focusin
<user-preview-card />
}
@defer (on timer(3s)) { // tras una duración
<newsletter-modal />
}
@defer (when userScrolledEnough()) { // condición de signal/expresión
<related-products />
}
Los triggers se combinan con ; como condiciones OR, y podés separar el prefetch del trigger de render:
@defer (on viewport; prefetch on idle) {
<heavy-chart [data]="data()" />
} @placeholder (minimum 500ms) {
<div class="skeleton"></div>
} @loading (after 100ms; minimum 1s) {
<spinner />
} @error {
<p>No se pudo cargar el gráfico.</p>
}
Acá el chunk del gráfico se descarga cuando el navegador está ocioso (prefetch on idle) pero se renderiza recién cuando entra al viewport. Cuando el usuario scrollea hasta él, el código ya está en caché: cero espera.
@placeholder (minimum 500ms): evita el parpadeo del placeholder si el contenido llega demasiado rápido.@loading (after 100ms; minimum 1s): solo muestra el spinner si la carga tarda más de 100 ms, y lo mantiene al menos 1 s para que no titile.@error: fallback si elimport()falla (red caída, chunk borrado por un deploy).
Por qué @defer mueve LCP e INP
Cada bloque diferido es JavaScript que no se descarga, parsea ni ejecuta en la carga inicial. Menos bytes en el chunk de la ruta → bootstrap más rápido → LCP más bajo. Menos código ejecutándose en el hilo principal durante los primeros segundos → menos bloqueo → INP más bajo. El costo se paga después, distribuido en el tiempo, cuando cada pieza realmente se necesita.
Cuidado con CLS. Si el @placeholder mide 40 px de alto y el contenido final mide 400 px, cuando se resuelva empujará todo lo de abajo: un salto de layout que dispara CLS. Dimensioná el placeholder para que ocupe el mismo espacio que el contenido final (mismo alto, mismo aspect ratio).
SSR e hydration: pintar antes de tener JavaScript
Con una SPA pura, el navegador recibe un HTML casi vacío y nada se pinta hasta que Angular arranca. El LCP queda a merced de la descarga y ejecución del bundle. Con SSR (@angular/ssr), el servidor renderiza el HTML ya poblado: el usuario ve contenido de inmediato, mucho antes de que llegue el JavaScript. Eso adelanta el LCP de forma dramática.
El problema clásico de SSR era la hydration destructiva: al arrancar en el cliente, Angular tiraba el DOM del servidor y lo volvía a construir, causando parpadeo y un pico de trabajo. La hydration no destructiva (estable desde hace varias versiones) reutiliza el DOM existente en vez de recrearlo.
// app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withEventReplay()),
],
};
withEventReplay() captura los clicks y eventos que el usuario dispara antes de que termine la hydration y los reproduce cuando el componente ya está vivo. Sin esto, esos primeros clicks se perderían: el usuario ve contenido (gracias a SSR) pero la app todavía no responde, y la interacción cae en el vacío. Reproducir esos eventos mejora la INP percibida en los primeros segundos.
Hydration incremental: hidratar solo lo que hace falta
La hydration “clásica” hidrata todo el árbol de una vez: aunque el HTML ya está pintado, Angular tiene que ejecutar el JS de cada componente para hacerlo interactivo, y eso es un bloque grande de trabajo en el hilo principal justo después del LCP—penalizando la INP.
La hydration incremental (Angular 22, estable) rompe ese monolito. Con provideClientHydration(), se activa automáticamente y te deja marcar bloques @defer con un trigger hydrate: el HTML se renderiza en el servidor y se envía pintado, pero el JavaScript de ese bloque no se descarga ni se hidrata hasta que se cumple el trigger.
// El bloque se ve desde el primer instante (SSR lo pintó),
// pero solo se vuelve interactivo al interactuar con él.
@defer (hydrate on interaction) {
<product-configurator [sku]="sku()" />
} @placeholder {
<static-product-summary [sku]="sku()" />
}
Triggers de hydration disponibles: hydrate on idle, hydrate on viewport, hydrate on interaction, hydrate on hover, hydrate on timer(...), hydrate on immediate, hydrate when <condición> y hydrate never.
// Combinado: en cargas SSR usa el trigger de hydrate;
// en navegaciones posteriores del cliente usa el trigger normal.
@defer (on idle; hydrate on viewport) {
<analytics-dashboard />
}
// Contenido puramente estático: nunca gastar JS en hidratarlo.
@defer (hydrate never) {
<marketing-footer />
}
Puntos clave de la mecánica:
- Los triggers
hydrateaplican solo a la carga inicial SSR. En navegaciones cliente posteriores, se usa el trigger de render normal (on idle, etc.). - La hydration incremental habilita event replay automáticamente: los eventos previos a la hydration se encolan y se reproducen.
- Hydration top-down: si se dispara la hydration de un bloque hijo, todos sus bloques padre se hidratan primero.
- Permite tener contenido above the fold dentro de un
@defersin salto de layout: el template principal se renderiza durante la hydration, sin reemplazo de placeholder.
sequenceDiagram
participant S as Servidor (SSR)
participant B as Navegador
participant A as Angular (cliente)
S->>B: HTML ya pintado (LCP temprano)
Note over B: Usuario ve y puede scrollear
B->>A: Descarga bundle inicial (esqueleto)
A->>A: Hydration de lo above-the-fold
Note over A: Bloques @defer hydrate: DEHIDRATADOS
B-->>A: Usuario interactúa con un bloque
A->>A: Descarga chunk + hidrata solo ese bloque
Note over A: INP baja: menos JS por interacción
El resultado: LCP temprano (SSR), bundle inicial pequeño (solo se hidrata lo visible) e INP controlado (el trabajo de hydration se distribuye según la interacción real, no todo de golpe).
Bundle budgets: que el CI te frene antes que el usuario
Optimizar una vez no sirve si el bundle vuelve a crecer sin que nadie se entere. Los budgets en angular.json fallan el build cuando el tamaño supera un umbral: convierten “la app se puso lenta” en un error de CI concreto y accionable.
{
"budgets": [
{
"type": "initial",
"maximumWarning": "300kb",
"maximumError": "500kb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
},
{
"type": "bundle",
"name": "main",
"baseline": "200kb",
"maximumWarning": "10%",
"maximumError": "20%"
}
]
}
type: "initial": el bundle que se descarga antes de que la app arranque. Es el número que más correlaciona con LCP. Ponele un techo y defendelo.type: "anyComponentStyle": detecta hojas de estilo de componente infladas (típico: importar un framework CSS entero en un componente).type: "bundle"conbaseline+ porcentaje: alerta ante crecimientos relativos, útil para chunks que ya son grandes.
Cuando el build falla por budget, tenés una decisión explícita: o justificás el crecimiento (y subís el umbral) o lo revertís. Sin budget, el bundle crece silenciosamente hasta que alguien nota que la home tarda cuatro segundos.
Analizar el bundle: medir antes de cortar
No optimices a ciegas. Antes de decidir qué diferir o qué dependencia sacar, mirá qué está ocupando el bundle. El antipatrón número uno de esta sección es no medir.
# Generá el build con source maps
bun run build -- --source-map
# Analizá qué compone cada chunk
bunx source-map-explorer dist/**/*.js
source-map-explorer te da un treemap de qué módulo aporta cuántos kilobytes. Ahí suelen aparecer los sospechosos: una librería de fechas de 70 kB usada para formatear una sola fecha, un paquete de íconos entero importado por tres íconos, o un helper de lodash que arrastró todo lodash.
flowchart TD
A[Build con source maps] --> B[source-map-explorer]
B --> C{¿Dependencia pesada?}
C -->|Sí, y se usa poco| D[Reemplazar o diferir]
C -->|Sí, arrastra todo| E[Import selectivo / tree-shaking]
C -->|No| F[Revisar código eager<br/>que debería ser @defer]
D --> G[Re-medir]
E --> G
F --> G
G --> C
Tree-shaking y dependencias pesadas
El tree-shaking elimina el código que no se usa, pero solo si el paquete lo permite. Requisitos:
- Imports selectivos, no del barril completo:
// Antipatrón: fuerza a incluir toda la librería aunque uses una función
import _ from 'lodash';
const grupos = _.groupBy(items, 'categoria');
// Mejor: import selectivo (o usá el equivalente nativo si existe)
import groupBy from 'lodash-es/groupBy';
const grupos = groupBy(items, 'categoria');
// Object.groupBy(items, (i) => i.categoria) // nativo, sin dependencia
- Paquetes con ESM y
sideEffects: false: el tree-shaking depende de módulos ES estáticos. Una librería solo-CommonJS o con efectos secundarios declarados no se puede podar bien. - Pesá el costo de cada dependencia: una librería de 200 kB para resolver un problema de 20 líneas es una mala relación peso/beneficio. Antes de instalar, verificá el tamaño (bundlephobia, o midiéndolo). Para lo que sí necesitás pero es pesado y no es crítico al inicio (editores de texto enriquecido, motores de gráficos, mapas), la respuesta es
@defer, no eager.
NgOptimizedImage: LCP de imágenes sin dolor
Si el elemento LCP de tu página es una imagen (un hero, un banner), la imagen misma es la métrica. NgOptimizedImage aplica las mejores prácticas de carga de imágenes de forma declarativa.
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-hero',
imports: [NgOptimizedImage],
template: `
<!-- Imagen LCP: priority fuerza fetchpriority=high, loading=eager
y genera <link rel=preload> en SSR -->
<img ngSrc="hero.webp" width="1200" height="600" priority alt="Portada" />
<!-- Imágenes below-the-fold: lazy por defecto, con srcset responsivo -->
<img
ngSrc="producto.webp"
width="400"
height="300"
sizes="(max-width: 768px) 100vw, 33vw"
alt="Producto"
/>
`,
})
export class HeroComponent {}
Lo que te da:
width/heightobligatorios → reservan el espacio antes de que la imagen cargue → CLS = 0 por esa imagen. El directive falla el build si te olvidás las dimensiones, justamente para prevenir el salto de layout.priorityen la imagen LCP →fetchpriority="high",loading="eager"y un<link rel="preload">automático en SSR → la imagen crítica se descarga antes → LCP más bajo.loading="lazy"por defecto para el resto → no compiten con lo crítico.srcsetautomático → el navegador descarga el tamaño adecuado al viewport, sin bytes de más.
Complementá con preconnect al origen de imágenes si servís desde un CDN, y con estrategia de fuentes para no bloquear el render:
<!-- index.html -->
<link rel="preconnect" href="https://cdn.tu-dominio.com" />
<!-- Fuentes: preload + font-display: swap para evitar texto invisible (FOIT) -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin />
font-display: swap (en el @font-face) evita que el texto quede invisible mientras carga la fuente, mejorando el LCP de texto; combinado con dimensiones estables, evita el reflow que dispara CLS.
Antipatrones de carga inicial
- Importar todo eager. Rutas con
component:yimportestático en vez deloadComponent. Todo el árbol de features viaja en el bundle inicial. → Lazy loading por ruta +@deferpara lo pesado dentro de la vista. - Librerías gigantes para poco. 300 kB de librería para formatear una fecha o mostrar tres íconos. → Import selectivo, equivalente nativo, o diferir si es realmente necesario.
- No medir. Optimizar por intuición. Nunca corriste
source-map-explorerni tenés budgets. No sabés qué pesa. → Medí primero, poné budgets, dejá que el CI te avise. - Bloquear el hilo principal. Trabajo síncrono pesado (parseo de JSON enorme, cálculos O(n²), hydration monolítica) en el arranque. Sube INP. → Hydration incremental,
@defer, mover cómputo acomputedmemoizados o Web Workers. - Imágenes sin dimensiones.
<img src>sinwidth/height. Cada imagen que carga empuja el layout → CLS alto. →NgOptimizedImagecon dimensiones yprioritypara la LCP. - Placeholder de
@deferde distinto tamaño que el contenido final → salto de layout al resolver. → Dimensioná el placeholder al tamaño final. - Hidratar todo de una cuando media pantalla es estática. →
hydrate neverpara lo estático,hydrate on viewport/interactionpara lo interactivo.
Checklist
- Rutas grandes cargadas con
loadComponent/loadChildren, nuncaimportestático +component:. - Estrategia de precarga (
withPreloading) evaluada según el patrón de navegación real. - Componentes pesados o below-the-fold dentro de
@defercon el trigger adecuado (on viewport,on interaction,on idle). -
@placeholderdimensionado igual que el contenido final para no disparar CLS. -
prefetch on idleen los@deferque el usuario probablemente va a necesitar pronto. - SSR activo con
provideClientHydration();withEventReplay()o hydration incremental para no perder los primeros clicks. - Bloques
@defercon triggershydratepara distribuir la hydration;hydrate neveren lo puramente estático. - Budgets
initial,anyComponentStyleybundleconfigurados enangular.jsony verificados en CI. - Bundle analizado con
source-map-explorer; dependencias pesadas justificadas, reemplazadas o diferidas. - Imports selectivos (no barriles completos) y dependencias con ESM para permitir tree-shaking.
- Imagen LCP con
NgOptimizedImage+priority; el resto lazy;width/heightsiempre presentes. -
preconnectal CDN de imágenes ypreload+font-display: swappara fuentes críticas. - LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1 medidos en field data (percentil 75), no solo en tu laptop.
Con la carga inicial optimizada, el siguiente eslabón de calidad son los datos que el usuario ingresa. En el capítulo 8: Formularios vemos reactive forms tipados, Signal Forms, estrategias de validación y los antipatrones que convierten un formulario en una fuente de bugs.