Optimización de Core Web Vitals — Alcanza los objetivos de LCP, INP y CLS para rendimiento en el mundo real
Receta
// Instala la librería web-vitals para medición de usuarios reales
// npm install web-vitals
// app/components/WebVitals.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
const { name, value, rating } = metric;
// Envía al endpoint de analytics
fetch("/api/vitals", {
method: "POST",
body: JSON.stringify({ name, value, rating }),
});
});
return null;
}
// Objetivos:
// LCP (Largest Contentful Paint): menor a 2.5s
// INP (Interaction to Next Paint): menor a 200ms
// CLS (Cumulative Layout Shift): menor a 0.1
// TTFB (Time to First Byte): menor a 800ms
// FCP (First Contentful Paint): menor a 1.8sCuándo usarlo: Antes y después de cada despliegue a producción. Core Web Vitals son factores de clasificación de Google y se correlacionan directamente con el engagement y tasas de conversión de usuarios.
Ejemplo funcional
// ---- ANTES: Vitales pobres — LCP 4.1s, INP 380ms, CLS 0.28 ----
// app/page.tsx — Componente cliente con recursos bloqueantes
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion"; // 44KB blocking import
export default function HomePage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
// Fetch después de hidratación — retrasa LCP
useEffect(() => {
fetch("/api/products")
.then((r) => r.json())
.then((data) => {
setProducts(data);
setLoading(false);
});
}, []);
// Problema de INP: filtrado síncrono pesado en cada clic
const handleFilter = (category: string) => {
const sorted = products
.filter((p) => p.category === category)
.sort((a, b) => a.name.localeCompare(b.name))
.map((p) => ({ ...p, score: computeExpensiveScore(p) })); // 200ms+ sync
setProducts(sorted);
};
if (loading) return <div>Cargando...</div>;
return (
<div>
{/* CLS: Imagen sin dimensiones */}
<img src="/hero.jpg" alt="Hero" />
{/* CLS: Fuente externa causa reflujo de texto */}
<h1 style={{ fontFamily: "'Fancy Font', serif" }}>Nuestros productos</h1>
{/* CLS: Contenido dinámico empuja el layout */}
{products.length > 0 && (
<p className="text-sm text-gray-500">{products.length} productos encontrados</p>
)}
<div className="flex gap-2 my-4">
{categories.map((cat) => (
<button key={cat} onClick={() => handleFilter(cat)}>
{cat}
</button>
))}
</div>
<motion.div className="grid grid-cols-3 gap-4">
{products.map((p) => (
<div key={p.id}>
<img src={p.image} alt={p.name} /> {/* Sin lazy loading, sin optimización */}
<p>{p.name} — ${p.price}</p>
</div>
))}
</motion.div>
</div>
);
}
// ---- DESPUÉS: Buenos vitales — LCP 1.8s, INP 85ms, CLS 0.03 ----
// app/page.tsx — Server Component con recursos optimizados
import Image from "next/image";
import { Inter } from "next/font/google";
import { Suspense } from "react";
import { db } from "@/lib/db";
import { ProductGrid } from "./ProductGrid";
import heroImage from "@/public/hero.jpg";
const inter = Inter({ subsets: ["latin"], display: "swap" });
export default async function HomePage() {
// Corrección de LCP: fetch del lado del servidor — datos disponibles en primera renderización
const products = await db.product.findMany({
orderBy: { name: "asc" },
take: 50,
});
return (
<div className={inter.className}>
{/* Corrección de LCP: imagen optimizada con precarga de prioridad */}
<Image
src={heroImage}
alt="Product showcase featuring our latest collection"
priority
placeholder="blur"
sizes="100vw"
className="w-full h-auto"
/>
{/* Corrección de CLS: fuente auto-hospedada vía next/font — sin cambio de layout */}
<h1 className="text-4xl font-bold mt-8">Nuestros productos</h1>
{/* Corrección de CLS: espacio reservado para el contador de productos */}
<p className="text-sm text-gray-500 h-6">
{products.length} productos encontrados
</p>
<Suspense fallback={<div className="grid grid-cols-3 gap-4 h-96" />}>
<ProductGrid initialProducts={products} />
</Suspense>
</div>
);
}
// ProductGrid.tsx — Componente cliente con interacciones optimizadas
"use client";
import { useState, useTransition } from "react";
import Image from "next/image";
export function ProductGrid({ initialProducts }: { initialProducts: Product[] }) {
const [products, setProducts] = useState(initialProducts);
const [isPending, startTransition] = useTransition();
// Corrección de INP: useTransition marca el filtrado como no urgente
const handleFilter = (category: string) => {
startTransition(() => {
const filtered = initialProducts
.filter((p) => category === "all" || p.category === category)
.sort((a, b) => a.name.localeCompare(b.name));
setProducts(filtered);
});
};
return (
<div>
<div className="flex gap-2 my-4">
<button onClick={() => handleFilter("all")}>Todos</button>
{categories.map((cat) => (
<button key={cat} onClick={() => handleFilter(cat)}>
{cat}
</button>
))}
</div>
<div
className={`grid grid-cols-3 gap-4 ${isPending ? "opacity-60" : ""}`}
>
{products.map((p, index) => (
<div key={p.id}>
{/* Corrección de CLS: width + height previenen cambio de layout */}
{/* LCP: prioridad en las primeras 3 imágenes */}
<Image
src={p.image}
alt={p.name}
width={400}
height={300}
priority={index < 3}
sizes="(max-width: 768px) 100vw, 33vw"
className="rounded-lg"
/>
<p className="mt-2 font-medium">{p.name} — ${p.price}</p>
</div>
))}
</div>
</div>
);
}Lo que esto demuestra:
- LCP: datos del servidor + imagen de prioridad + precarga = 4.1s a 1.8s (56% más rápido)
- INP:
useTransitiondifiere filtrado pesado = 380ms a 85ms (78% más rápido) - CLS: dimensiones de
next/image+next/font+ espacio reservado = 0.28 a 0.03 (89% reducción) - TTFB: renderización de Server Component transmite HTML inmediatamente
- Se eliminó la importación
framer-motion: 44KB de ahorro del bundle inicial
Análisis profundo
Cómo funciona
- LCP (Largest Contentful Paint) mide cuándo el elemento visible más grande (imagen, encabezado, video) termina de renderizarse. El objetivo es menos de 2.5 segundos. Factores clave: tiempo de respuesta del servidor, tiempo de carga de recursos, recursos que bloquean el renderizado, retraso de renderizado del lado del cliente.
- INP (Interaction to Next Paint) mide la latencia desde la entrada del usuario (clic, toque, pulsación de tecla) hasta la siguiente actualización visual. El objetivo es menos de 200ms. Factores clave: tareas de JavaScript largas que bloquean el hilo principal, computaciones síncronas pesadas, tamaño del DOM grande.
- CLS (Cumulative Layout Shift) mide el movimiento visual inesperado del contenido de la página. El objetivo es menos de 0.1. Factores clave: imágenes sin dimensiones, inyección de contenido dinámico, fuentes que causan reflujo de texto, anuncios o embeds que se cargan de forma asincrónica.
- TTFB (Time to First Byte) mide cuánto tiempo espera el navegador el primer byte de la respuesta. El objetivo es menos de 800ms. Factores clave: tiempo de procesamiento del servidor, consultas a base de datos, latencia de red, configuración de CDN.
- FCP (First Contentful Paint) mide cuándo se pinta el primer texto o imagen. El objetivo es menos de 1.8 segundos. Factores clave: CSS/JS que bloquean el renderizado, carga de fuentes, tiempo de respuesta del servidor.
Variaciones
Medición con la librería web-vitals:
// app/vitals.ts
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
function sendToAnalytics(metric: { name: string; value: number; rating: string }) {
const body = JSON.stringify(metric);
// Usa sendBeacon para confiabilidad durante descarga de página
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
fetch("/api/vitals", { method: "POST", body, keepalive: true });
}
}
export function reportWebVitals() {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}Optimización de INP con useTransition y useDeferredValue:
"use client";
import { useState, useTransition, useDeferredValue } from "react";
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
// Filtrado diferido — escribir se mantiene responsivo
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
/>
<div className={isStale ? "opacity-50" : ""}>
{filtered.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>
);
}Lista de verificación para prevención de CLS:
// 1. Siempre establece dimensiones de imagen
<Image src={url} alt="" width={800} height={600} />
// 2. Reserva espacio para contenido dinámico
<div className="min-h-[200px]">
{data ? <Content data={data} /> : <Skeleton />}
</div>
// 3. Usa next/font en lugar de hojas de estilo externas
import { Inter } from "next/font/google";
// 4. Evita insertar contenido encima del contenido existente
// MAL: Banner aparece encima del pliegue después de cargar
// BIEN: Reserva espacio para el banner desde el inicio
// 5. Usa CSS contain para anuncios e embeds
<div style={{ contain: "layout", minHeight: 250 }}>
<AdSlot />
</div>Notas de TypeScript
- La librería
web-vitalsproporciona tipos TypeScript para todos los objetos de métrica:CLSMetric,INPMetric,LCPMetric, etc. useReportWebVitalsdenext/web-vitalsacepta un callback con tipoNextWebVitalsMetric.- El campo
ratingse tipifica como"good" | "needs-improvement" | "poor".
Gotchas
-
Medir en desarrollo en lugar de producción — El modo de desarrollo añade gastos generales de React DevTools, double-renders de Strict Mode y retrasos de HMR. Los vitales medidos en dev son 2-5x peores que en producción. Corrección: Siempre mide en compilaciones de producción:
npm run build && npm start, o usa Lighthouse con URLs de producción. -
Datos de laboratorio frente a datos de campo — Las puntuaciones de Lighthouse (datos de laboratorio) pueden diferir significativamente de las métricas de usuarios reales (datos de campo) debido a condiciones de red, capacidades de dispositivo y distribución geográfica. Corrección: Usa ambos: Lighthouse para depuración, Chrome UX Report (CrUX) o
web-vitalspara datos de usuarios reales. -
El elemento LCP no es lo que esperas — El elemento LCP puede ser una imagen de fondo, un póster de video, o un bloque de texto — no siempre la imagen principal. Corrección: Abre Lighthouse, expande el diagnóstico de LCP, y verifica qué elemento se identifica. Optimiza ese elemento específico.
-
INP no es solo el primer clic — INP mide la peor interacción en toda la sesión de página (en el percentil 98). Un primer clic rápido pero una operación de filtrado lenta aún produce una puntuación de INP pobre. Corrección: Perfila todas las rutas interactivas, no solo la carga inicial de página.
-
CLS se mide en toda la sesión — CLS se acumula en toda la sesión de página, no solo en la carga inicial. Un popup que aparece 30 segundos después de la carga aún contribuye a CLS. Corrección: Reserva espacio para todo el contenido dinámico, incluyendo modales, toasts, banners y secciones lazy-loaded.
-
El almacenamiento en caché oculta problemas de TTFB — Las revisitas con recursos en caché muestran TTFB excelente, ocultando problemas que experimentan los visitantes por primera vez. Corrección: Prueba con caché deshabilitado (DevTools Network -> Disable cache) y desde diferentes ubicaciones geográficas.
Alternativas
| Enfoque | Compensación |
|---|---|
| Lighthouse | Pruebas de laboratorio; repetible, no refleja usuarios reales |
| PageSpeed Insights | Usa datos de campo CrUX; limitado a páginas con suficiente tráfico |
Librería web-vitals | Medición de usuarios reales; requiere infraestructura de analytics |
| Chrome UX Report (CrUX) | Datos de campo de usuarios de Chrome; ventana móvil de 28 días |
| Vercel Speed Insights | Automático para despliegues de Vercel; específico de proveedor |
| New Relic o Datadog RUM | Monitoreo empresarial; costo, configuración compleja |
FAQs
¿Cuáles son los tres Core Web Vitals y sus umbrales de objetivo?
- LCP (Largest Contentful Paint): menor a 2.5 segundos
- INP (Interaction to Next Paint): menor a 200 milisegundos
- CLS (Cumulative Layout Shift): menor a 0.1
¿Por qué obtener datos en un useEffect del lado del cliente daña LCP?
- El navegador debe descargar, parsear y ejecutar el bundle de JS antes de que se ejecute
useEffect. - El fetch solo comienza después de la hidratación, añadiendo un viaje completo antes de que el elemento más grande pueda renderizarse.
- Corrección: Obtén datos en un Server Component para que el HTML incluya el contenido en la primera renderización.
¿Cómo mejora useTransition INP para interacciones pesadas como filtrado?
useTransitionmarca la actualización de estado como no urgente, así React mantiene la UI receptiva a la entrada del usuario.- El navegador puede pintar el siguiente frame antes de que se complete el re-renderizado costoso.
- Los usuarios ven un estado pendiente (p.ej., opacidad de
isPending) en lugar de una UI congelada.
¿Qué causa CLS de imágenes y cómo lo corrige next/image?
- Las imágenes sin
widthyheightexplícitos causan cambio de layout porque el navegador no puede reservar espacio hasta que la imagen carga. next/imagerequiere dimensiones, automáticamente reserva la relación de aspecto correcta y previene cambio de layout.
¿Por qué deberías usar navigator.sendBeacon en lugar de fetch para reportar vitals?
sendBeacones confiable durante descarga de página (p.ej., cuando el usuario navega).- Un
fetchregular puede ser cancelado por el navegador cuando la página se desmonta. - Usa
fetchconkeepalive: truecomo fallback cuandosendBeaconno está disponible.
Gotcha: ¿Por qué son Core Web Vitals medidos en modo desarrollo no confiables?
- El modo dev añade gastos generales de React DevTools, double-renders de Strict Mode y retrasos de HMR.
- Los vitales en dev son típicamente 2-5x peores que en producción.
- Corrección: Siempre mide con una compilación de producción:
npm run build && npm start.
¿Cuál es la diferencia entre datos de laboratorio y datos de campo para vitals?
- Datos de laboratorio (Lighthouse) se ejecutan en un ambiente controlado; repetible pero no refleja usuarios reales.
- Datos de campo (CrUX,
web-vitals) vienen de sesiones de usuarios reales con dispositivos y redes variadas. - Usa ambos: laboratorio para depuración, campo para seguimiento de rendimiento en el mundo real.
Gotcha: ¿Por qué tu puntuación de CLS podría ser pobre incluso si la carga inicial se ve estable?
- CLS se acumula en toda la sesión de página, no solo en la carga inicial.
- Un popup, toast o sección lazy-loaded que aparece 30 segundos después aún contribuye.
- Corrección: Reserva espacio para todo el contenido dinámico, incluyendo modales y banners.
¿Cómo tipificas el callback de métrica de web-vitals en TypeScript?
import type { CLSMetric, INPMetric, LCPMetric } from "web-vitals";
function handleMetric(metric: CLSMetric | INPMetric | LCPMetric) {
// metric.rating se tipifica como "good" | "needs-improvement" | "poor"
console.log(metric.name, metric.value, metric.rating);
}¿Cómo tipificas el callback useReportWebVitals en una aplicación Next.js con TypeScript?
- El callback recibe un tipo
NextWebVitalsMetricdenext/web-vitals. - El campo
ratinges"good" | "needs-improvement" | "poor". - El campo
namees una unión de nombres de métrica como"LCP","INP","CLS", etc.
¿Por qué la prop priority debe estar solo en una o dos imágenes por página?
prioritydeshabilita lazy loading y agrega una etiqueta<link rel="preload">.- Precarga de demasiadas imágenes compite por ancho de banda y puede realmente ralentizar la imagen de LCP.
- Solo la imagen identificada como el elemento Largest Contentful Paint necesita
priority.
¿Cómo difiere useDeferredValue de useTransition para optimización de INP?
useTransitionenvuelve una actualización de estado para marcarla como no urgente.useDeferredValuedifiere el consumo de un valor, mostrando datos obsoletos mientras React re-renderiza.- Ambos mantienen la UI receptiva;
useDeferredValuees mejor cuando no controlas la actualización de estado.
¿Qué es TTFB y por qué el almacenamiento en caché oculta sus problemas?
- TTFB (Time to First Byte) mide cuánto espera el navegador el primer byte de respuesta del servidor; el objetivo es menor a 800ms.
- Las revisitas con recursos en caché muestran TTFB excelente, ocultando problemas de servidor lento que experimentan visitantes por primera vez.
- Corrección: Prueba con caché deshabilitado y desde diferentes ubicaciones geográficas.
Relacionado
- Image & Font Performance — Optimizando imágenes LCP y eliminando CLS de fuentes
- Bundle Optimization — Reduciendo JS que bloquea INP y FCP
- Suspense & Streaming — Streaming para TTFB y FCP más rápidos
- Performance Checklist — Auditoría sistemática de CWV con aplicación de CI