Rendimiento frontend: Core Web Vitals y optimización
La velocidad no es un lujo: es un factor de conversión, de SEO y de experiencia. Este tutorial explica qué miden los Core Web Vitals, cómo medirlos con Lighthouse y DevTools, y las técnicas concretas para optimizar carga, imágenes, caché y renderizado en React y Next.js. Prerequisitos: React y Next.js.
Core Web Vitals
Los Core Web Vitals son tres métricas que Google usa para evaluar la experiencia de usuario. Se miden en dos contextos: lab (entorno controlado, Lighthouse) y field (datos reales de usuarios, CrUX). Cada una tiene un umbral "bueno" y uno "mejorar".
LCP — Largest Contentful Paint
Mide cuándo se pinta el elemento más grande visible (imagen hero, título grande, vídeo). Es una métrica de carga.
- Bueno: ≤ 2.5s.
- Mejorar: > 4s.
Para mejorarlo: optimiza el TTFB (servidor/CDN), precarga la imagen hero con priority, sirve imágenes en WebP/AVIF y evita que el CSS bloqueante retrase el primer paint.
INP — Interaction to Next Paint
Mide la latencia de las interacciones: desde que el usuario hace clic o toca hasta que la UI responde. Sustituyó a FID en 2024 y es una métrica de capacidad de respuesta.
- Bueno: ≤ 200ms.
- Mejorar: > 500ms.
Para mejorarlo: reduce el trabajo del hilo principal (menos JS, menos re-renders), evita tareas largas que bloqueen la interacción y usa requestIdleCallback para trabajo no urgente.
CLS — Cumulative Layout Shift
Mide cuánto se desplaza el layout de forma inesperada mientras la página carga. Es una métrica de estabilidad visual.
- Bueno: ≤ 0.1.
- Mejorar: > 0.25.
Las causas típicas: imágenes sin dimensiones, fuentes que cambian de tamaño al cargar, contenido inyectado por encima del viewport y animaciones que mueven elementos.
Lighthouse y herramientas
Antes de optimizar, mide. El flujo habitual:
- Chrome DevTools → Performance: graba un trace y muestra qué tareas bloquean el hilo principal, cuánto tarda cada recurso y dónde está el cuello de botella.
- Lighthouse: auditoría automatizada de performance, accesibilidad, SEO y buenas prácticas. Se ejecuta desde DevTools, CLI o CI.
- Web Vitals extension: muestra LCP, INP y CLS en tiempo real mientras navegas.
- Dashboard de Next.js: con
next devy la flag experimental, obtienes métricas de rendimiento de cada ruta en desarrollo.
// Ejecutar Lighthouse desde CLI en CI
npx lighthouse https://tu-sitio.com --view --output=json --output-path=./report.json
// Con Next.js, activa el análisis de bundle
ANALYZE=true npm run buildOptimización de carga
El objetivo es que el usuario vea contenido útil lo antes posible y que el JavaScript que no necesita no se descargue.
Code splitting
El code splitting divide el bundle en trozos que se cargan bajo demanda. En Next.js, cada página ya genera su propio chunk. Para componentes pesados dentro de una página, usa next/dynamic:
// components/Chart.tsx — se carga solo cuando se necesita
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("@/components/Chart"), {
ssr: false, // solo cliente (usa window, canvas…)
loading: () => <p>Cargando gráfico…</p>,
});
export function Dashboard() {
return <Chart data={data} />;
}En React puro (sin Next), el equivalente es React.lazy con Suspense:
import { lazy, Suspense } from "react";
const HeavyEditor = lazy(() => import("./HeavyEditor"));
export function App() {
return (
<Suspense fallback={<Spinner />}>
<HeavyEditor />
</Suspense>
);
}Loading states
Un loading state honesto evita que el usuario piense que la app está rota. En Next.js, loading.tsx muestra un placeholder mientras el segmento se resuelve, y React.lazy usa fallback:
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 w-48 rounded bg-gray-200 dark:bg-gray-800" />
<div className="h-40 rounded bg-gray-200 dark:bg-gray-800" />
</div>
);
}Prefetch
El prefetch descarga recursos antes de que el usuario los pida. Next.js prefetchea los enlaces Link visibles en el viewport automáticamente. Para datos, React Query permite prefetchar queries:
import { useQueryClient } from "@tanstack/react-query";
export function usePrefetchPost(id: string) {
const queryClient = useQueryClient();
return () => {
queryClient.prefetchQuery({
queryKey: ["post", id],
queryFn: () => fetchPost(id),
});
};
}Imágenes
Las imágenes suelen ser el mayor peso de una página. Tres palancas: formato, dimensiones y carga diferida.
Formatos modernos
WebP y AVIF comprimen mucho mejor que JPEG/PNG con calidad visual similar. AVIF suele ganar en peso, WebP en compatibilidad. Sirve siempre el formato más ligero que soporte el navegador.
next/image
next/image optimiza automáticamente: genera múltiples tamaños (srcset), sirve WebP/AVIF, aplica loading="lazy" por defecto y exige dimensiones para evitar CLS:
import Image from "next/image";
export function Hero() {
return (
<Image
src="/portada.jpg"
alt="Portada del artículo"
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, 50vw"
priority // la imagen hero se precarga
/>
);
}- width/height: obligatorios, reservan el espacio y eliminan CLS.
- sizes: le dice al navegador qué ancho usará, para elegir la variante correcta del
srcset. - priority: precarga la imagen (solo para la hero o LCP).
- loading="lazy": por defecto en
next/image; las imágenes fuera del viewport no se descargan hasta acercarse.
priority en todas las imágenes: solo la que es el LCP. Precargar varias imágenes compite por el ancho de banda y ralentiza la carga de la principal.Caching y revalidación
La caché evita repetir trabajo. A nivel HTTP, las respuestas se cachean con Cache-Control:
// Respuesta de una API pública
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
// max-age: 1h fresca en caché
// stale-while-revalidate: 24h más sirviendo la copia vieja
// mientras se revalida en segundo planoEn Next.js, el ISR (Incremental Static Regeneration) combina estático con revalidación: la página se genera en build y se regenera en segundo plano cuando expira revalidate:
// pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
const post = await fetchPost(params.slug);
return {
props: { post },
revalidate: 3600, // regenera como máximo cada hora
};
}
export async function getStaticPaths() {
const posts = await fetchAllPosts();
return {
paths: posts.map((p) => ({ params: { slug: p.slug } })),
fallback: "blocking", // páginas nuevas se generan bajo demanda
};
}stale-while-revalidate y el ISR comparten la misma filosofía: el usuario nunca espera por contenido fresco; recibe la copia en caché y la actualización llega después. Es la diferencia entre "rápido pero desactualizado" y "lento pero perfecto".Memoización en React
La memoización evita trabajo repetido, pero no es gratis: cada useMemo o React.memo añade comparaciones. Úsala con criterio.
React.memo
Evita que un componente se re-renderice si sus props no cambiaron. Útil para componentes pesados que reciben props estables:
import { memo } from "react";
interface RowProps {
id: number;
name: string;
onSelect: (id: number) => void;
}
const Row = memo(function Row({ id, name, onSelect }: RowProps) {
return <li onClick={() => onSelect(id)}>{name}</li>;
});useMemo y useCallback
useMemo cachea un valor calculado; useCallback cachea una función. Ambos estabilizan referencias para que React.memo funcione:
import { useCallback, useMemo } from "react";
export function Parent() {
// la lista se calcula una vez
const items = useMemo(() => buildItems(), []);
// la función mantiene la misma referencia entre renders
const handleSelect = useCallback((id: number) => {
setSelected(id);
}, []);
return <RowList items={items} onSelect={handleSelect} />;
}React.memo solo añade coste. La regla: memoiza cuando hay un componente pesado, una lista larga, o un valor caro de calcular — y mide antes y después. La memoización prematura es una deuda de complejidad.Eliminar CLS
El CLS se elimina reservando espacio para todo lo que se carga después. Tres técnicas clave:
- Dimensiones fijas: imágenes y vídeos con
width/heightoaspect-ratio. - Fuentes: usa
font-display: swappara que el texto se muestre con una fuente de respaldo mientras carga la real. - Contenedores: reserva altura para contenido que llega tarde (ads, embeds, listas).
/* Reserva espacio con aspect-ratio en vez de dejar que empuje */
.media {
aspect-ratio: 16 / 9;
width: 100%;
}
/* Fuente que no desplaza el layout al cargar */
@font-face {
font-family: "MiFuente";
src: url("/fonts/mi-fuente.woff2") format("woff2");
font-display: swap;
}min-height desde el primer render.Bundle analysis
Un bundle grande se traduce en más descarga y más parseo. @next/bundle-analyzer genera un mapa visual de qué pesa en tu bundle:
// next.config.mjs
import withBundleAnalyzer from "@next/bundle-analyzer"({
enabled: process.env.ANALYZE === "true",
});
export default withBundleAnalyzer({
// tu config de Next
});Con el análisis en mano, busca dependencias pesadas y pregúntate:
- ¿Puedo importar solo la parte que uso? (imports de árbol,
import { useMemo } from "react"). - ¿Puedo cargarla de forma diferida con
dynamic? - ¿Hay una alternativa más ligera? (una utilidad propia en vez de lodash completa).
Ejercicios
El rendimiento no es una fase final: es una disciplina que se aplica mientras escribes. Medir con Lighthouse y datos de campo, optimizar imágenes y carga, cachear con criterio y memoizar solo donde aporta. Cada técnica de este tutorial ataca una métrica concreta — y juntas convierten una página "que funciona" en una que se siente instantánea.