Web Core Vitals en Angular
Kevin Dávila
Web Core Vitals en Angular
Hay una verdad incómoda sobre el rendimiento web que los equipos de Angular suelen ignorar hasta que ya es tarde: Google usa las Core Web Vitals como señal directa de posicionamiento. No como una sugerencia, sino como un factor real de ranking. Y si tu aplicación Angular carga lento, salta elementos en pantalla o no responde con fluidez, estás perdiendo tanto tráfico orgánico como usuarios.
Qué son las Web Core Vitals y por qué importan
Las Core Web Vitals son métricas definidas por Google para medir la experiencia real del usuario en una página web. No son métricas abstractas; se calculan sobre datos reales de Chrome (Chrome User Experience Report) y se basan en el percentil 75 de todas las visitas al sitio.
Largest Contentful Paint (LCP) mide cuánto tarda en aparecer el elemento más grande visible en el viewport — normalmente una imagen hero, un bloque de texto principal, o un video. Refleja la percepción de velocidad de carga.
Bueno: ≤ 2.5 segundos
Necesita mejora: 2.5 – 4 segundos
Pobre: > 4 segundos
Interaction to Next Paint (INP) reemplazó a First Input Delay en marzo de 2024. Mide la latencia de todas las interacciones del usuario con la página durante su visita — clicks, taps, teclado — y reporta el peor caso observado.
Bueno: ≤ 200 ms
Necesita mejora: 200 – 500 ms
Pobre: > 500 ms
Source: Introducing INP to Core Web Vitals | Google Search Central Blog
Cumulative Layout Shift (CLS) mide la estabilidad visual. Cuantifica cuánto se mueven los elementos visibles de forma inesperada durante la carga.
Bueno: ≤ 0.1
Necesita mejora: 0.1 – 0.25
Pobre: > 0.25
Source: Defining Core Web Vitals thresholds | web.dev
El impacto en negocio es real. Swappie mejoró su LCP móvil un 55% y su CLS un 91%, lo que se tradujo en un aumento del 42% en ingresos móviles. Vodafone Italia mejoró su LCP un 31% y consiguió un 8% más en ventas. En 2025, solo el 48% de sitios móviles y el 56% de sitios de escritorio pasan las Core Web Vitals correctamente.
Source: The business impact of Core Web Vitals | web.dev
Cómo Angular impacta estas métricas
Angular tiene un modelo de renderizado que, por defecto, genera todo el DOM en el cliente. El navegador descarga el bundle de JavaScript, lo parsea, lo ejecuta, y entonces Angular construye la vista. Este proceso tiene un coste directo sobre LCP e INP.
Las versiones recientes del framework han cambiado esto de forma significativa:
Angular v17: Hydration estable con mejoras consistentes del 40-50% en LCP.
Angular v18: Hydration parcial y bloques @defer maduros, con mejoras de hasta el 45% en LCP en aplicaciones reales y reducciones del bundle del 30-50%.
Angular v19: Hydration incremental en developer preview.
Angular v20: Hydration incremental estable y mode zoneless estable, con reducciones de bundle de ~20% adicionales.
Source: Incremental Hydration in Angular | Syncfusion Blog
Imágenes sin optimizar y su coste en LCP
El elemento LCP más común en aplicaciones Angular es una imagen hero. Y la forma más frecuente de arruinar el LCP es cargar esa imagen sin ninguna priorización.
Patrón problemático
<!-- app-hero.component.html -->
<img src="/assets/hero.jpg" alt="Hero image">
Este código tiene tres problemas. No le dice al navegador que esta imagen es crítica para el renderizado inicial, no genera srcset automático para distintas resoluciones, y no reserva espacio, lo que puede causar layout shift.
Solución con NgOptimizedImage
// app-hero.component.ts
import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-hero',
standalone: true,
imports: [NgOptimizedImage],
template: `
<img
ngSrc="/assets/hero.jpg"
width="1200"
height="600"
priority
alt="Hero image"
>
`
})
export class HeroComponent {}
El atributo priority activa fetchpriority=high y loading=eager, y en SSR genera un preload link en el <head>. Los atributos width y height son obligatorios con NgOptimizedImage y previenen el layout shift reservando el espacio antes de que la imagen cargue.
Source: Optimizing images with NgOptimizedImage | angular.dev
Para imágenes que no son el LCP, la directiva aplica loading=lazy por defecto:
// product-card.component.ts
import { Component, Input } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-product-card',
standalone: true,
imports: [NgOptimizedImage],
template: `
<img
[ngSrc]="imageUrl"
width="400"
height="300"
alt="Product image"
>
`
})
export class ProductCardComponent {
@Input() imageUrl = '';
}
Bundle inicial excesivo y su impacto en INP
El INP mide la capacidad de respuesta durante toda la sesión del usuario. Si el hilo principal está ocupado parseando un bundle enorme al inicio, cualquier interacción temprana se verá degradada.
Patrón problemático — imports ansiosos en el router
// app.routes.ts — MAL
import { Routes } from '@angular/router';
import { DashboardComponent } from './dashboard/dashboard.component';
import { SettingsComponent } from './settings/settings.component';
import { ReportsComponent } from './reports/reports.component';
import { AdminPanelComponent } from './admin/admin-panel.component';
export const routes: Routes = [
{ path: 'dashboard', component: DashboardComponent },
{ path: 'settings', component: SettingsComponent },
{ path: 'reports', component: ReportsComponent },
{ path: 'admin', component: AdminPanelComponent },
];
Todos los componentes se importan estáticamente. Angular CLI los incluye todos en el bundle principal, aunque el usuario nunca visite esas rutas.
Corrección con lazy loading
// app.routes.ts — CORRECTO
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
},
{
path: 'settings',
loadComponent: () =>
import('./settings/settings.component').then(m => m.SettingsComponent),
},
{
path: 'reports',
loadComponent: () =>
import('./reports/reports.component').then(m => m.ReportsComponent),
},
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
];
Esto genera chunks separados por ruta que solo se descargan cuando el usuario navega a ellas. La reducción del bundle inicial puede ser del 70-85% dependiendo del tamaño de la aplicación.
Contenido pesado en el viewport inicial con @defer
Otro error frecuente es renderizar componentes complejos — tablas de datos, gráficas, carruseles — en la carga inicial, incluso cuando están fuera del viewport o el usuario podría nunca llegar a verlos.
Patrón problemático
// page.component.ts — MAL
import { Component } from '@angular/core';
import { HeavyChartComponent } from './heavy-chart.component';
import { DataTableComponent } from './data-table.component';
import { CommentsWidgetComponent } from './comments-widget.component';
@Component({
selector: 'app-page',
standalone: true,
imports: [HeavyChartComponent, DataTableComponent, CommentsWidgetComponent],
template: `
<app-hero />
<app-heavy-chart />
<app-data-table />
<app-comments-widget />
`
})
export class PageComponent {}
Todo el código de estos componentes se incluye en el bundle de la página, independientemente de si el usuario los ve o no.
Corrección con bloques @defer
// page.component.ts — CORRECTO
import { Component } from '@angular/core';
@Component({
selector: 'app-page',
standalone: true,
template: `
<app-hero />
@defer (on viewport) {
<app-heavy-chart />
} @placeholder {
<div class="chart-skeleton" style="height: 400px;"></div>
}
@defer (on viewport) {
<app-data-table />
} @placeholder {
<div class="table-skeleton" style="height: 300px;"></div>
}
@defer (on idle) {
<app-comments-widget />
} @placeholder {
<div class="comments-skeleton"></div>
}
`
})
export class PageComponent {}
Los componentes dentro de @defer se separan en chunks independientes. El trigger on viewport los carga cuando entran en el área visible; on idle los carga cuando el hilo principal está desocupado.
El @placeholder es importante para CLS: mantiene el espacio visual reservado mientras el componente no ha cargado, evitando saltos de layout.
Source: Deferred loading with @defer | angular.dev
Layout shift por contenido dinámico sin reserva de espacio
El CLS se dispara cuando elementos aparecen o cambian de tamaño después del renderizado inicial sin que el navegador haya reservado su espacio.
Patrón problemático — banner cargado dinámicamente
// banner.component.ts — MAL
import { Component, OnInit } from '@angular/core';
import { BannerService } from './banner.service';
@Component({
selector: 'app-banner',
standalone: true,
template: `
@if (banner) {
<div class="banner">{{ banner.text }}</div>
}
`
})
export class BannerComponent implements OnInit {
banner: { text: string } | null = null;
constructor(private bannerService: BannerService) {}
ngOnInit() {
this.bannerService.getBanner().subscribe(data => {
this.banner = data;
});
}
}
Cuando banner llega del servidor, el elemento <div> aparece y empuja el contenido debajo, generando un layout shift.
Corrección reservando el espacio
// banner.component.ts — CORRECTO
import { Component, OnInit } from '@angular/core';
import { BannerService } from './banner.service';
@Component({
selector: 'app-banner',
standalone: true,
styles: [`
.banner-container {
min-height: 60px;
}
`],
template: `
<div class="banner-container">
@if (banner) {
<div class="banner">{{ banner.text }}</div>
}
</div>
`
})
export class BannerComponent implements OnInit {
banner: { text: string } | null = null;
constructor(private bannerService: BannerService) {}
ngOnInit() {
this.bannerService.getBanner().subscribe(data => {
this.banner = data;
});
}
}
El contenedor con min-height reserva el espacio desde el primer renderizado. Cuando llega el contenido, no desplaza nada.
SSR e Hydration para mejorar LCP drásticamente
Las aplicaciones Angular que solo renderizan en cliente (SPA puro) tienen un LCP estructuralmente alto: el navegador tiene que descargar el bundle, ejecutar JavaScript, y entonces Angular construye el DOM. Con SSR + hydration, el servidor envía el HTML renderizado directamente.
Configuración de SSR con Hydration en Angular v17+
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(),
],
};
// app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
Hydration incremental con Angular v20
Para aplicaciones más complejas, la hydration incremental permite que el servidor renderice toda la página pero que el cliente hidrate solo los componentes necesarios según demanda:
// app.config.ts — Angular v20
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(withIncrementalHydration()),
],
};
// dashboard.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
<app-summary-cards />
@defer (on viewport; hydrate on interaction) {
<app-analytics-chart />
} @placeholder {
<div class="chart-placeholder" style="height: 400px;"></div>
}
`
})
export class DashboardComponent {}
El componente app-analytics-chart se renderiza en el servidor (mejora LCP), pero el cliente no lo hidrata hasta que el usuario interactúa con él (mejora INP reduciendo el trabajo inicial del hilo principal).
Source: Hydration | angular.dev, Incremental Hydration | angular.dev
Change detection innecesario con Zone.js
Zone.js parchea todas las operaciones asíncronas del navegador y dispara change detection en Angular después de cada una. En aplicaciones con muchos eventos asincrónicos — timers, peticiones HTTP, eventos de usuario — esto bloquea el hilo principal frecuentemente y deteriora el INP.
Patrón problemático — componente que dispara CD innecesariamente
// tracker.component.ts — MAL
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-tracker',
standalone: true,
template: `<p>{{ status }}</p>`
})
export class TrackerComponent implements OnInit {
status = 'active';
ngOnInit() {
// Este setInterval dispara change detection cada 500ms en toda la app
setInterval(() => {
this.checkStatus();
}, 500);
}
private checkStatus() {
// Lógica que no cambia el estado visible
}
}
Corrección con Signals y OnPush
// tracker.component.ts — CORRECTO
import { Component, OnInit, signal, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-tracker',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ status() }}</p>`
})
export class TrackerComponent implements OnInit {
status = signal('active');
ngOnInit() {
setInterval(() => {
this.checkStatus();
}, 500);
}
private checkStatus() {
// CD solo se dispara si status.set() es llamado con un valor distinto
}
}
Con ChangeDetectionStrategy.OnPush y signals, Angular solo actualiza la vista cuando el signal cambia de valor. El intervalo no causa renders innecesarios.
Para proyectos en Angular v20, el modo zoneless elimina Zone.js completamente:
// main.ts — Angular v20 zoneless
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, {
...appConfig,
providers: [
...appConfig.providers,
provideZonelessChangeDetection(),
],
});
Source: Zoneless | angular.dev
Estrategia de precarga en el router
Lazy loading resuelve el bundle inicial, pero puede crear una pausa perceptible cuando el usuario navega. Las estrategias de precarga equilibran ambos problemas.
Sin estrategia de precarga (defecto)
// app.config.ts — sin precarga
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig = {
providers: [
provideRouter(routes),
],
};
Los chunks lazy se cargan solo cuando el usuario navega a esa ruta. Puede generar una pausa visible en conexiones lentas.
Con precarga selectiva
// selective-preload.strategy.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class SelectivePreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
return route.data?.['preload'] ? load() : of(null);
}
}
// app.routes.ts — rutas con flag de precarga
export const routes: Routes = [
{
path: 'dashboard',
data: { preload: true },
loadComponent: () =>
import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
},
{
path: 'reports',
data: { preload: false },
loadComponent: () =>
import('./reports/reports.component').then(m => m.ReportsComponent),
},
];
// app.config.ts — con estrategia selectiva
import { provideRouter, withPreloading } from '@angular/router';
import { SelectivePreloadStrategy } from './selective-preload.strategy';
import { routes } from './app.routes';
export const appConfig = {
providers: [
provideRouter(routes, withPreloading(SelectivePreloadStrategy)),
],
};
Solo se precargan los módulos marcados con preload: true — típicamente los más usados, como el dashboard — sin desperdiciar ancho de banda en rutas que pocos usuarios visitan.
Source: Route preloading strategies in Angular | web.dev
Cómo medir el estado actual de tu aplicación
Antes de optimizar, hay que medir. Estas herramientas trabajan sobre datos reales o de laboratorio:
PageSpeed Insights analiza una URL y muestra tanto datos de campo (Chrome UX Report) como datos de laboratorio (Lighthouse). Es el punto de partida más directo.
Chrome DevTools — Performance panel permite grabar sesiones de usuario y ver exactamente qué bloquea el hilo principal, cuándo aparece el LCP, y qué causa layout shifts.
Google Search Console — Core Web Vitals report muestra datos reales de usuarios segmentados por tipo de dispositivo y URL.
web-vitals library permite capturar métricas directamente desde el código Angular y enviarlas a tu sistema de analytics:
// core-web-vitals.service.ts
import { Injectable } from '@angular/core';
import { onLCP, onINP, onCLS } from 'web-vitals';
@Injectable({ providedIn: 'root' })
export class CoreWebVitalsService {
init(): void {
onLCP(metric => this.report(metric));
onINP(metric => this.report(metric));
onCLS(metric => this.report(metric));
}
private report(metric: { name: string; value: number; rating: string }): void {
console.log(`[CWV] ${metric.name}: ${metric.value} (${metric.rating})`);
// Enviar a tu sistema de analytics aquí
}
}
// app.config.ts — inicializar el servicio al arrancar
import { ApplicationConfig, APP_INITIALIZER } from '@angular/core';
import { CoreWebVitalsService } from './core-web-vitals.service';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_INITIALIZER,
useFactory: (service: CoreWebVitalsService) => () => service.init(),
deps: [CoreWebVitalsService],
multi: true,
},
],
};
Resumen de patrones
Problema | Métrica afectada | Solución Angular |
|---|---|---|
Imagen hero sin prioridad | LCP |
|
Imports ansiosos en router | LCP, INP |
|
Componentes pesados en viewport | LCP, INP |
|
Contenido dinámico sin espacio reservado | CLS | Contenedor con |
Ausencia de SSR | LCP | SSR + |
Change detection excesivo | INP | Signals + OnPush / Zoneless |
Sin estrategia de precarga | Navegación |
|
El rendimiento en Angular no es un proyecto de una semana. Es un conjunto de decisiones de arquitectura que se toman desde el inicio — o se corrigen gradualmente con herramientas que el framework ya incluye.
Keywords: Core Web Vitals Angular, LCP Angular optimización, INP Angular rendimiento, CLS Angular layout shift, NgOptimizedImage defer SSR
Meta description: Aprende qué son las Core Web Vitals y cómo optimizarlas en Angular con SSR, hydration incremental, NgOptimizedImage y bloques @defer con ejemplos reales de código.