Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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.
// ---- 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:
useTransition difiere filtrado pesado = 380ms a 85ms (78% más rápido)next/image + next/font + espacio reservado = 0.28 a 0.03 (89% reducción)framer-motion: 44KB de ahorro del bundle inicialMedició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>web-vitals proporciona tipos TypeScript para todos los objetos de métrica: CLSMetric, INPMetric, LCPMetric, etc.useReportWebVitals de next/web-vitals acepta un callback con tipo NextWebVitalsMetric.rating se tipifica como "good" | "needs-improvement" | "poor".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-vitals para 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.
| 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 |
useEffect.useTransition marca la actualización de estado como no urgente, así React mantiene la UI receptiva a la entrada del usuario.isPending) en lugar de una UI congelada.width y height explícitos causan cambio de layout porque el navegador no puede reservar espacio hasta que la imagen carga.next/image requiere dimensiones, automáticamente reserva la relación de aspecto correcta y previene cambio de layout.sendBeacon es confiable durante descarga de página (p.ej., cuando el usuario navega).fetch regular puede ser cancelado por el navegador cuando la página se desmonta.fetch con keepalive: true como fallback cuando sendBeacon no está disponible.npm run build && npm start.web-vitals) vienen de sesiones de usuarios reales con dispositivos y redes variadas.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);
}NextWebVitalsMetric de next/web-vitals.rating es "good" | "needs-improvement" | "poor".name es una unión de nombres de métrica como "LCP", "INP", "CLS", etc.priority deshabilita lazy loading y agrega una etiqueta <link rel="preload">.priority.useTransition envuelve una actualización de estado para marcarla como no urgente.useDeferredValue difiere el consumo de un valor, mostrando datos obsoletos mientras React re-renderiza.useDeferredValue es mejor cuando no controlas la actualización de estado.Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥