Carga inicial y Core Web Vitals

Por: Artiko
angularbuenas-practicascore-web-vitalsdeferhydrationlazy-loadingrendimiento

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étricaQué mideUmbral “bueno”Palanca principal en Angular
LCP (Largest Contentful Paint)Carga: cuándo se pinta el elemento más grande visible≤ 2.5 sTamaño del bundle inicial, SSR, imágenes
INP (Interaction to Next Paint)Interactividad: latencia de la peor interacción≤ 200 msJS en el hilo principal, hydration, OnPush/zoneless
CLS (Cumulative Layout Shift)Estabilidad visual: saltos de layout≤ 0.1Dimensiones 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

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 NgModule de feature: loadChildren apunta a un array de Routes exportado, 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.

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:

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%"
    }
  ]
}

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:

// 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

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:

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

Checklist

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.