Saltar al contenido
·26 min de lectura

Core Web Vitals en 2026: Lo que realmente marca la diferencia

Olvida la teoría — esto es lo que realmente hice para bajar el LCP a menos de 2.5s, el CLS a cero y el INP a menos de 200ms en un sitio Next.js en producción. Técnicas específicas, no consejos vagos.

Compartir:X / TwitterLinkedIn

Pasé la mayor parte de dos semanas haciendo este sitio rápido. No "se ve rápido en una auditoría de Lighthouse en mi MacBook M3" rápido. Realmente rápido. Rápido en un teléfono Android de $150 con una conexión 4G inestable en un túnel de metro. Rápido donde importa.

El resultado: LCP por debajo de 1.8s, CLS en 0.00, INP por debajo de 120ms. Los tres en verde en datos CrUX, no solo puntuaciones de laboratorio. Y aprendí algo en el proceso — la mayoría de los consejos de rendimiento en internet están desactualizados, son vagos, o ambas cosas.

"Optimiza tus imágenes" no es un consejo. "Usa lazy loading" sin contexto es peligroso. "Minimiza JavaScript" es obvio pero no te dice nada sobre qué recortar.

Esto es lo que realmente hice, en el orden que importó.

Por qué los Core Web Vitals siguen importando en 2026#

Seré directo: Google usa los Core Web Vitals como señal de posicionamiento. No la única señal, ni siquiera la más importante. La relevancia del contenido, los backlinks y la autoridad del dominio siguen dominando. Pero en los márgenes — donde dos páginas tienen contenido y autoridad comparables — el rendimiento es el desempate. Y en internet, millones de páginas viven en esos márgenes.

Pero olvida el SEO por un segundo. La verdadera razón para preocuparse por el rendimiento son los usuarios. Los datos no han cambiado mucho en los últimos cinco años:

  • El 53% de las visitas móviles se abandonan si una página tarda más de 3 segundos en cargar (investigación de Google/SOASTA, sigue vigente)
  • Cada 100ms de latencia cuesta aproximadamente un 1% en conversiones (hallazgo original de Amazon, validado repetidamente)
  • Los usuarios que experimentan cambios de diseño tienen significativamente menos probabilidades de completar una compra o rellenar un formulario

Los Core Web Vitals en 2026 consisten en tres métricas:

MétricaQué mideBuenoNecesita mejoraPobre
LCPRendimiento de carga≤ 2.5s2.5s – 4.0s> 4.0s
CLSEstabilidad visual≤ 0.10.1 – 0.25> 0.25
INPCapacidad de respuesta≤ 200ms200ms – 500ms> 500ms

Estos umbrales no han cambiado desde que INP reemplazó a FID en marzo de 2024. Pero las técnicas para alcanzarlos han evolucionado, especialmente en el ecosistema React/Next.js.

LCP: El que más importa#

Largest Contentful Paint mide cuándo el elemento visible más grande en el viewport termina de renderizarse. Para la mayoría de las páginas, esto es una imagen hero, un encabezado o un bloque grande de texto.

Paso 1: Encuentra tu elemento LCP real#

Antes de optimizar cualquier cosa, necesitas saber cuál es tu elemento LCP. La gente asume que es su imagen hero. A veces es una fuente web renderizando el <h1>. A veces es una imagen de fondo aplicada vía CSS. A veces es un frame del poster de un <video>.

Abre Chrome DevTools, ve al panel de Performance, graba una carga de página y busca el marcador "LCP". Te dice exactamente qué elemento activó el LCP.

También puedes usar la librería web-vitals para registrarlo programáticamente:

tsx
import { onLCP } from "web-vitals";
 
onLCP((metric) => {
  console.log("LCP element:", metric.entries[0]?.element);
  console.log("LCP value:", metric.value, "ms");
});

En este sitio, el elemento LCP resultó ser la imagen hero en la página principal y el primer párrafo de texto en las publicaciones del blog. Dos elementos diferentes, dos estrategias de optimización diferentes.

Paso 2: Precarga el recurso LCP#

Si tu elemento LCP es una imagen, lo más impactante que puedes hacer es precargarla. Por defecto, el navegador descubre las imágenes cuando parsea el HTML, lo que significa que la solicitud de la imagen no comienza hasta después de que el HTML se descarga, se parsea y se alcanza la etiqueta <img>. La precarga mueve ese descubrimiento al inicio.

En Next.js, puedes agregar un enlace de precarga en tu layout o página:

tsx
import Head from "next/head";
 
export default function HeroSection() {
  return (
    <>
      <Head>
        <link
          rel="preload"
          as="image"
          href="/images/hero-optimized.webp"
          type="image/webp"
          fetchPriority="high"
        />
      </Head>
      <section className="relative h-[600px]">
        <img
          src="/images/hero-optimized.webp"
          alt="Hero banner"
          width={1200}
          height={600}
          fetchPriority="high"
          decoding="sync"
        />
      </section>
    </>
  );
}

Observa fetchPriority="high". Esta es la API más nueva de Fetch Priority, y es un cambio radical. Sin ella, el navegador usa sus propias heurísticas para priorizar recursos — y esas heurísticas a menudo se equivocan, especialmente cuando tienes múltiples imágenes above the fold.

En este sitio, agregar fetchPriority="high" a la imagen LCP redujo el LCP en ~400ms. Esa es la mayor ganancia que he obtenido de un cambio de una sola línea.

Paso 3: Elimina los recursos que bloquean el renderizado#

CSS bloquea el renderizado. Todo. Si tienes una hoja de estilos de 200KB cargada vía <link rel="stylesheet">, el navegador no pintará nada hasta que esté completamente descargada y parseada.

La solución tiene tres partes:

  1. Incrusta el CSS crítico — Extrae el CSS necesario para el contenido above the fold e incrústalo en una etiqueta <style> en el <head>. Next.js hace esto automáticamente cuando usas CSS Modules o Tailwind con purging adecuado.

  2. Difiere el CSS no crítico — Si tienes hojas de estilo para contenido below the fold (una librería de animaciones del footer, un componente de gráficos), cárgalas de forma asíncrona:

html
<link
  rel="preload"
  href="/styles/charts.css"
  as="style"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link rel="stylesheet" href="/styles/charts.css" />
</noscript>
  1. Elimina el CSS no utilizado — Tailwind CSS v4 hace esto automáticamente con su motor JIT. Pero si estás importando librerías CSS de terceros, audítalas. Encontré una librería de componentes que importaba 180KB de CSS para un solo componente de tooltip. La reemplacé con un componente personalizado de 20 líneas y ahorré 170KB.

Paso 4: Tiempo de respuesta del servidor (TTFB)#

El LCP no puede ser rápido si el TTFB es lento. Si tu servidor tarda 800ms en responder, tu LCP será de al menos 800ms + todo lo demás.

En este sitio (Node.js + PM2 + Nginx en un VPS), medí el TTFB en alrededor de 180ms en un hit frío. Esto es lo que hice para mantenerlo ahí:

  • ISR (Incremental Static Regeneration) para las publicaciones del blog — las páginas se pre-renderizan en tiempo de build y se revalidan periódicamente. La primera visita sirve un archivo estático directamente desde la caché del proxy inverso de Nginx.
  • Cabeceras de caché en el edgeCache-Control: public, s-maxage=3600, stale-while-revalidate=86400 en páginas estáticas.
  • Compresión Gzip/Brotli en Nginx — reduce el tamaño de transferencia en un 60-80%.
bash
# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
 
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;

Mi antes/después en LCP:

  • Antes de la optimización: 3.8s (percentil 75, CrUX)
  • Después de preload + fetchPriority + compresión: 1.8s
  • Mejora total: 53% de reducción

CLS: Muerte por mil desplazamientos#

Cumulative Layout Shift mide cuánto se mueve el contenido visible durante la carga de la página. Un CLS de 0 significa que nada se desplazó. Un CLS por encima de 0.1 significa que algo está molestando visualmente a tus usuarios.

CLS es la métrica que más subestiman los desarrolladores. No lo notas en tu máquina de desarrollo rápida con todo en caché. Tus usuarios lo notan en sus teléfonos, en conexiones lentas, donde las fuentes cargan tarde y las imágenes aparecen una por una.

Los culpables habituales#

1. Imágenes sin dimensiones explícitas

Esta es la causa más común de CLS. Cuando una imagen carga, empuja el contenido debajo de ella hacia abajo. La solución es vergonzosamente simple: siempre especifica width y height en las etiquetas <img>.

tsx
// MAL — causa cambio de diseño
<img src="/photo.jpg" alt="Team photo" />
 
// BIEN — el navegador reserva espacio antes de que cargue la imagen
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />

Si estás usando <Image> de Next.js, esto se maneja automáticamente siempre y cuando proporciones dimensiones o uses fill con un contenedor padre dimensionado.

Pero aquí está la trampa: si usas el modo fill, el contenedor padre debe tener dimensiones explícitas o la imagen causará un CLS:

tsx
// MAL — el padre no tiene dimensiones
<div className="relative">
  <Image src="/photo.jpg" alt="Team" fill />
</div>
 
// BIEN — el padre tiene relación de aspecto explícita
<div className="relative aspect-video w-full">
  <Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>

2. Fuentes web causando FOUT/FOIT

Cuando una fuente personalizada carga, el texto renderizado con la fuente de respaldo se re-renderiza con la fuente personalizada. Si las dos fuentes tienen métricas diferentes (casi siempre las tienen), todo se desplaza.

La solución moderna es font-display: swap combinado con fuentes de respaldo ajustadas en tamaño:

tsx
// Using next/font — the best approach for Next.js
import { Inter } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  // next/font automatically generates size-adjusted fallback fonts
  // This eliminates CLS from font swapping
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font es genuinamente una de las mejores cosas de Next.js. Descarga las fuentes en tiempo de build, las aloja localmente (sin solicitud a Google Fonts en runtime), y genera fuentes de respaldo ajustadas en tamaño para que el intercambio de la fuente de respaldo a la fuente personalizada cause cero cambio de diseño. Medí el CLS por fuentes en 0.00 después de cambiar a next/font. Antes, con un <link> estándar de Google Fonts, era 0.04-0.08.

3. Inyección de contenido dinámico

Anuncios, banners de cookies, barras de notificación — cualquier cosa que se inyecte en el DOM después del renderizado inicial causa CLS si empuja el contenido hacia abajo.

La solución: reservar espacio para el contenido dinámico antes de que cargue.

tsx
// Cookie banner — reserve space at the bottom
function CookieBanner() {
  const [accepted, setAccepted] = useState(false);
 
  if (accepted) return null;
 
  return (
    // Fixed positioning doesn't cause CLS because it
    // doesn't affect document flow
    <div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
      <p>We use cookies. You know the drill.</p>
      <button onClick={() => setAccepted(true)}>Accept</button>
    </div>
  );
}

Usar position: fixed o position: absolute para elementos dinámicos es un enfoque libre de CLS porque estos elementos no afectan el flujo normal del documento.

4. El truco de aspect-ratio en CSS

Para contenedores responsivos donde conoces la relación de aspecto pero no las dimensiones exactas, usa la propiedad CSS aspect-ratio:

tsx
// Video embed without CLS
function VideoEmbed({ src }: { src: string }) {
  return (
    <div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
      <iframe
        src={src}
        className="w-full h-full"
        title="Embedded video"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media"
        allowFullScreen
      />
    </div>
  );
}

La utilidad aspect-video (que es aspect-ratio: 16/9) reserva exactamente la cantidad correcta de espacio. Sin desplazamiento cuando carga el iframe.

5. Pantallas skeleton

Para contenido que carga de forma asíncrona (datos de API, componentes dinámicos), muestra un skeleton que coincida con las dimensiones esperadas:

tsx
function PostCardSkeleton() {
  return (
    <div className="animate-pulse rounded-lg border p-4">
      <div className="h-48 w-full rounded bg-gray-200" />
      <div className="mt-4 space-y-2">
        <div className="h-6 w-3/4 rounded bg-gray-200" />
        <div className="h-4 w-full rounded bg-gray-200" />
        <div className="h-4 w-5/6 rounded bg-gray-200" />
      </div>
    </div>
  );
}
 
function PostList() {
  const { data: posts, isLoading } = usePosts();
 
  if (isLoading) {
    return (
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <PostCardSkeleton key={i} />
        ))}
      </div>
    );
  }
 
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {posts?.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Lo clave es que PostCardSkeleton y PostCard deben tener las mismas dimensiones. Si el skeleton mide 200px de alto y la tarjeta real mide 280px, seguirás teniendo un desplazamiento.

Mis resultados de CLS:

  • Antes: 0.12 (el intercambio de fuente solo era 0.06)
  • Después: 0.00 — literalmente cero, en miles de cargas de página en datos CrUX

INP: El novato que muerde#

Interaction to Next Paint reemplazó a First Input Delay en marzo de 2024, y es una métrica fundamentalmente más difícil de optimizar. FID solo medía el retraso antes de que se procesara la primera interacción. INP mide cada interacción a lo largo del ciclo de vida de la página e informa la peor (en el percentil 75).

Esto significa que una página puede tener un FID excelente pero un INP terrible si, por ejemplo, hacer clic en un menú desplegable 30 segundos después de la carga desencadena un reflujo de 500ms.

Qué causa un INP alto#

  1. Tareas largas en el hilo principal — Cualquier ejecución de JavaScript que tome más de 50ms bloquea el hilo principal. Las interacciones del usuario que ocurren durante una tarea larga tienen que esperar.
  2. Re-renderizados costosos en React — Una actualización de estado que causa que 200 componentes se re-rendericen toma tiempo. El usuario hace clic en algo, React reconcilia, y el pintado no ocurre durante 300ms.
  3. Layout thrashing — Leer propiedades de layout (como offsetHeight) y luego escribirlas (como cambiar style.height) en un bucle obliga al navegador a recalcular el layout de forma síncrona.
  4. DOM grande — Más nodos DOM significa recálculo de estilos y layout más lento. Un DOM con 5,000 nodos es notablemente más lento que uno con 500.

Dividiendo tareas largas con scheduler.yield()#

La técnica más impactante para INP es dividir tareas largas para que el navegador pueda procesar interacciones del usuario entre fragmentos:

tsx
async function processLargeDataset(items: DataItem[]) {
  const results: ProcessedItem[] = [];
 
  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));
 
    // Every 10 items, yield to the browser
    // This lets pending user interactions get processed
    if (i % 10 === 0 && "scheduler" in globalThis) {
      await scheduler.yield();
    }
  }
 
  return results;
}

scheduler.yield() está disponible en Chrome 129+ (septiembre 2024) y es la forma recomendada de ceder el control al hilo principal. Para navegadores que no lo soportan, puedes hacer fallback con un wrapper de setTimeout(0):

tsx
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

useTransition para actualizaciones no urgentes#

React 18+ nos da useTransition, que le dice a React que ciertas actualizaciones de estado no son urgentes y pueden ser interrumpidas por trabajo más importante (como responder a la entrada del usuario):

tsx
import { useState, useTransition } from "react";
 
function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();
 
  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
 
    // This update is urgent — the input must reflect the keystroke immediately
    setQuery(value);
 
    // This update is NOT urgent — filtering 10,000 items can wait
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }
 
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
        className="w-full rounded border px-4 py-2"
      />
      {isPending && (
        <p className="mt-2 text-sm text-gray-500">Filtering...</p>
      )}
      <ul className="mt-4 space-y-2">
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

Sin startTransition, escribir en el input de búsqueda se sentiría lento porque React intentaría filtrar 10,000 elementos de forma síncrona antes de actualizar el DOM. Con startTransition, el input se actualiza inmediatamente, y el filtrado ocurre en segundo plano.

Medí el INP en una página de herramientas que tenía un manejador de entrada complejo. Antes de useTransition: 380ms de INP. Después: 90ms de INP. Eso es una mejora del 76% con un cambio en la API de React.

Debouncing de manejadores de entrada#

Para manejadores que desencadenan operaciones costosas (llamadas a API, cómputo pesado), aplica debounce:

tsx
import { useCallback, useRef } from "react";
 
function useDebounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  return useCallback(
    ((...args: unknown[]) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
      timeoutRef.current = setTimeout(() => fn(...args), delay);
    }) as T,
    [fn, delay]
  );
}
 
// Usage
function LiveSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
 
  const search = useDebounce(async (query: string) => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = await response.json();
    setResults(data.results);
  }, 300);
 
  return (
    <input
      type="text"
      onChange={(e) => search(e.target.value)}
      placeholder="Search..."
    />
  );
}

300ms es mi valor de debounce predeterminado. Es lo suficientemente corto como para que los usuarios no noten el retraso, lo suficientemente largo como para evitar disparar en cada pulsación de tecla.

Web Workers para cómputo pesado#

Si tienes cómputo genuinamente pesado (parseo de JSON grande, manipulación de imágenes, cálculos complejos), muévelo fuera del hilo principal por completo:

tsx
// worker.ts
self.addEventListener("message", (event) => {
  const { data, operation } = event.data;
 
  switch (operation) {
    case "sort": {
      // This could take 500ms for large datasets
      const sorted = data.sort((a: number, b: number) => a - b);
      self.postMessage({ result: sorted });
      break;
    }
    case "filter": {
      const filtered = data.filter((item: DataItem) =>
        complexFilterLogic(item)
      );
      self.postMessage({ result: filtered });
      break;
    }
  }
});
tsx
// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
 
function useWorker() {
  const workerRef = useRef<Worker>();
 
  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/worker.ts", import.meta.url)
    );
    return () => workerRef.current?.terminate();
  }, []);
 
  const process = useCallback(
    (operation: string, data: unknown): Promise<unknown> => {
      return new Promise((resolve) => {
        if (!workerRef.current) return;
 
        workerRef.current.onmessage = (event) => {
          resolve(event.data.result);
        };
 
        workerRef.current.postMessage({ operation, data });
      });
    },
    []
  );
 
  return { process };
}

Los Web Workers operan en un hilo separado, así que incluso un cómputo de 2 segundos no afectará el INP en absoluto. El hilo principal queda libre para manejar las interacciones del usuario.

Mis resultados de INP:

  • Antes: 340ms (la peor interacción era una herramienta de prueba de regex con manejo de entrada complejo)
  • Después de useTransition + debouncing: 110ms
  • Mejora: 68% de reducción

Las ganancias específicas de Next.js#

Si estás en Next.js (13+ con App Router), tienes acceso a algunas primitivas de rendimiento poderosas que la mayoría de los desarrolladores no explotan completamente.

next/image — Pero configurado correctamente#

next/image es genial, pero la configuración por defecto deja rendimiento sobre la mesa:

tsx
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
    deviceSizes: [640, 750, 828, 1080, 1200],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year
  },
};
 
export default nextConfig;

Configuraciones clave:

  • formats: ["image/avif", "image/webp"] — AVIF es 20-50% más pequeño que WebP. El orden importa: Next.js intenta AVIF primero, hace fallback a WebP, luego al formato original.
  • minimumCacheTTL — Por defecto son 60 segundos. Para un blog, las imágenes no cambian. Ponlas en caché durante un año.
  • deviceSizes e imageSizes — Los valores por defecto incluyen 3840px. A menos que estés sirviendo imágenes 4K, recorta esta lista. Cada tamaño genera una imagen cacheada separada, y los tamaños no usados desperdician espacio en disco y tiempo de build.

Y siempre usa el prop sizes para decirle al navegador a qué tamaño se renderizará la imagen:

tsx
// Full-width hero image
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="100vw"
  priority // LCP image — don't lazy load it!
/>
 
// Card image in a responsive grid
<Image
  src="/card.jpg"
  alt="Card"
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Sin sizes, el navegador podría descargar una imagen de 1200px para un espacio de 300px. Son bytes desperdiciados y tiempo desperdiciado.

El prop priority en la imagen LCP es crítico. Desactiva el lazy loading y agrega fetchPriority="high" automáticamente. Si tu elemento LCP es un next/image, solo agrega priority y ya habrás avanzado la mayor parte del camino.

next/font — Fuentes con cero cambio de diseño#

Cubrí esto en la sección de CLS, pero merece énfasis. next/font es la única solución de carga de fuentes que he visto que logra consistentemente cero CLS:

tsx
import { Inter, JetBrains_Mono } from "next/font/google";
 
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});
 
const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-mono",
});
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html
      lang="en"
      className={`${inter.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}

Dos fuentes, cero CLS, cero solicitudes externas en runtime. Las fuentes se descargan en tiempo de build y se sirven desde tu propio dominio.

Streaming con Suspense#

Aquí es donde Next.js se vuelve realmente interesante para el rendimiento. Con el App Router, puedes transmitir partes de la página al navegador a medida que estén listas:

tsx
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
 
export default function BlogPage() {
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
      <div className="lg:col-span-2">
        {/* This loads fast — stream it immediately */}
        <h1 className="text-4xl font-bold">Blog</h1>
 
        {/* This requires a database query — stream it when ready */}
        <Suspense fallback={<PostListSkeleton />}>
          <PostList />
        </Suspense>
      </div>
 
      <aside>
        {/* Sidebar can load independently */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
      </aside>
    </div>
  );
}

El navegador recibe el esqueleto (encabezado, navegación, layout) inmediatamente. La lista de posts y la barra lateral se transmiten a medida que sus datos están disponibles. El usuario ve una carga inicial rápida, y el contenido se llena progresivamente.

Esto es particularmente poderoso para LCP. Si tu elemento LCP es el encabezado (no la lista de posts), se renderiza inmediatamente sin importar cuánto tarde la consulta a la base de datos.

Configuración de segmento de ruta#

Next.js te permite configurar el caché y la revalidación a nivel de segmento de ruta:

tsx
// app/blog/page.tsx
// Revalidate this page every hour
export const revalidate = 3600;
 
// app/tools/[slug]/page.tsx
// These tool pages are fully static — generate at build time
export const dynamic = "force-static";
 
// app/api/search/route.ts
// API route — never cache
export const dynamic = "force-dynamic";

En este sitio, las publicaciones del blog usan revalidate = 3600 (1 hora). Las páginas de herramientas usan force-static porque su contenido nunca cambia entre despliegues. La API de búsqueda usa force-dynamic porque cada solicitud es única.

El resultado: la mayoría de las páginas se sirven desde la caché estática, el TTFB es inferior a 50ms para páginas cacheadas, y el servidor apenas se esfuerza.

Herramientas de medición: Confía en los datos, no en tus ojos#

Tu percepción del rendimiento no es confiable. Tu máquina de desarrollo tiene 32GB de RAM, un SSD NVMe y una conexión de gigabit. Tus usuarios no.

La pila de medición que uso#

1. Panel de Performance de Chrome DevTools

La herramienta más detallada disponible. Graba una carga de página, mira el flamechart, identifica tareas largas, encuentra recursos que bloquean el renderizado. Aquí es donde paso la mayor parte de mi tiempo de depuración.

Cosas clave a buscar:

  • Esquinas rojas en las tareas = tareas largas (>50ms)
  • Eventos de Layout/Paint desencadenados por JavaScript
  • Bloques grandes de "Evaluate Script" (demasiado JavaScript)
  • Cascada de red mostrando recursos descubiertos tardíamente

2. Lighthouse

Bueno para una verificación rápida, pero no optimices para las puntuaciones de Lighthouse. Lighthouse se ejecuta en un entorno simulado con throttling que no coincide perfectamente con las condiciones del mundo real. He visto páginas con puntuación de 98 en Lighthouse y 4s de LCP en campo.

Usa Lighthouse para orientación direccional, no como marcador.

3. PageSpeed Insights

La herramienta más importante para sitios en producción porque muestra datos reales de CrUX — mediciones reales de usuarios reales de Chrome en los últimos 28 días. Los datos de laboratorio te dicen lo que podría pasar. Los datos de CrUX te dicen lo que pasa.

4. La librería web-vitals

Agrega esto a tu sitio en producción para recopilar métricas de usuarios reales:

tsx
// components/analytics/WebVitals.tsx
"use client";
 
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
 
function sendToAnalytics(metric: Metric) {
  // Send to your analytics endpoint
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
  });
 
  // Use sendBeacon so it doesn't block page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon("/api/vitals", body);
  } else {
    fetch("/api/vitals", {
      body,
      method: "POST",
      keepalive: true,
    });
  }
}
 
export function WebVitals() {
  useEffect(() => {
    onCLS(sendToAnalytics);
    onINP(sendToAnalytics);
    onLCP(sendToAnalytics);
    onFCP(sendToAnalytics);
    onTTFB(sendToAnalytics);
  }, []);
 
  return null;
}

Esto te da tus propios datos tipo CrUX, pero con más detalle. Puedes segmentar por página, tipo de dispositivo, velocidad de conexión, región geográfica — lo que necesites.

5. Chrome User Experience Report (CrUX)

El conjunto de datos BigQuery de CrUX es gratuito y contiene datos acumulados de 28 días para millones de orígenes. Si tu sitio recibe suficiente tráfico, puedes consultar tus propios datos:

sql
SELECT
  origin,
  p75_lcp,
  p75_cls,
  p75_inp,
  form_factor
FROM
  `chrome-ux-report.materialized.metrics_summary`
WHERE
  origin = 'https://yoursite.com'
  AND yyyymm = 202603

La lista negra de la cascada#

Los scripts de terceros son el asesino de rendimiento número uno en la mayoría de los sitios web. Esto es lo que encontré y lo que hice al respecto.

Google Tag Manager (GTM)#

GTM en sí mismo pesa ~80KB. Pero GTM carga otros scripts — analytics, píxeles de marketing, herramientas de pruebas A/B. He visto configuraciones de GTM que cargan 15 scripts adicionales totalizando 2MB.

Mi enfoque: No uses GTM en producción. Carga los scripts de analytics directamente, difiere todo, y usa loading="lazy" para scripts que pueden esperar:

tsx
// Instead of GTM loading everything
// Load only what you need, when you need it
 
export function AnalyticsScript() {
  return (
    <script
      defer
      src="https://analytics.example.com/script.js"
      data-website-id="your-id"
    />
  );
}

Si absolutamente debes usar GTM, cárgalo después de que la página sea interactiva:

tsx
"use client";
 
import { useEffect } from "react";
 
export function DeferredGTM({ containerId }: { containerId: string }) {
  useEffect(() => {
    // Wait until after page load to inject GTM
    const timer = setTimeout(() => {
      const script = document.createElement("script");
      script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
      script.async = true;
      document.head.appendChild(script);
    }, 3000); // 3 second delay
 
    return () => clearTimeout(timer);
  }, [containerId]);
 
  return null;
}

Sí, perderás datos de usuarios que rebotan en los primeros 3 segundos. En mi experiencia, es un compromiso que vale la pena. Esos usuarios no estaban convirtiendo de todos modos.

Widgets de chat#

Los widgets de chat en vivo (Intercom, Drift, Crisp) son de los peores infractores. Intercom solo carga más de 400KB de JavaScript. En una página donde el 2% de los usuarios realmente hacen clic en el botón de chat, son 400KB de JavaScript para el 98% de los usuarios.

Mi solución: Carga el widget al interactuar.

tsx
"use client";
 
import { useState } from "react";
 
export function ChatButton() {
  const [loaded, setLoaded] = useState(false);
 
  function loadChat() {
    if (loaded) return;
 
    // Load the chat widget script only when the user clicks
    const script = document.createElement("script");
    script.src = "https://chat-widget.example.com/widget.js";
    script.onload = () => {
      // Initialize the widget after script loads
      window.ChatWidget?.open();
    };
    document.head.appendChild(script);
    setLoaded(true);
  }
 
  return (
    <button
      onClick={loadChat}
      className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
      aria-label="Open chat"
    >
      {loaded ? "Loading..." : "Chat with us"}
    </button>
  );
}

JavaScript no utilizado#

Ejecuta Coverage en Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). Te muestra exactamente cuánto de cada script se usa realmente en la página actual.

En un sitio típico de Next.js, generalmente encuentro:

  • Librerías de componentes cargadas completamente — Importas Button de una librería de UI, pero toda la librería se empaqueta. Solución: usa librerías que soporten tree-shaking o importa desde sub-rutas (import Button from "lib/Button" en lugar de import { Button } from "lib").
  • Polyfills para navegadores modernos — Verifica si estás enviando polyfills para Promise, fetch, o Array.prototype.includes. En 2026, no los necesitas.
  • Feature flags muertos — Rutas de código detrás de feature flags que han estado "activados" durante seis meses. Elimina el flag y la rama muerta.

Uso el bundle analyzer de Next.js para encontrar chunks sobredimensionados:

tsx
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
 
const nextConfig = {
  // your config
};
 
export default process.env.ANALYZE === "true"
  ? withBundleAnalyzer({ enabled: true })(nextConfig)
  : nextConfig;
bash
ANALYZE=true npm run build

Esto abre un treemap visual de tus bundles. Encontré una librería de formateo de fechas de 120KB que reemplacé con Intl.DateTimeFormat nativo. Encontré un parser de markdown de 90KB importado en una página que no usaba markdown. Pequeñas ganancias que se acumulan.

CSS que bloquea el renderizado#

Mencioné esto en la sección de LCP, pero vale la pena repetirlo porque es muy común. Cada <link rel="stylesheet"> en el <head> bloquea el renderizado. Si tienes cinco hojas de estilo, el navegador espera a que las cinco se descarguen antes de pintar cualquier cosa.

Next.js con Tailwind maneja esto bien — el CSS se incrusta y es mínimo. Pero si estás importando CSS de terceros, audítalo:

tsx
// MAL — carga el CSS de toda la librería en cada página
import "some-library/dist/styles.css";
 
// MEJOR — importación dinámica para que solo cargue en las páginas que lo necesitan
const SomeComponent = dynamic(
  () => import("some-library").then((mod) => {
    // CSS is imported inside the dynamic component
    import("some-library/dist/styles.css");
    return mod.SomeComponent;
  }),
  { ssr: false }
);

Una historia real de optimización#

Permíteme recorrer la optimización real de la página de herramientas de este sitio. Es una página con más de 15 herramientas interactivas, cada una con su propio componente, y algunas de ellas (como el probador de regex y el formateador JSON) son pesadas en JavaScript.

El punto de partida#

Mediciones iniciales (datos CrUX, móvil, percentil 75):

  • LCP: 3.8s — Pobre
  • CLS: 0.12 — Necesita mejora
  • INP: 340ms — Pobre

Puntuación de Lighthouse: 62.

La investigación#

Análisis de LCP: El elemento LCP era el encabezado de la página (<h1>), que debería renderizarse instantáneamente. Pero estaba retrasado por:

  1. Un archivo CSS de 200KB de una librería de componentes (bloqueante del renderizado)
  2. Una fuente personalizada cargada vía CDN de Google Fonts (FOIT de 800ms en conexiones lentas)
  3. TTFB de 420ms porque la página se renderizaba en el servidor en cada solicitud sin caché

Análisis de CLS: Tres fuentes:

  1. Intercambio de fuente del fallback de Google Fonts a la fuente personalizada: 0.06
  2. Tarjetas de herramientas cargando sin reserva de altura: 0.04
  3. Un banner de cookies inyectado en la parte superior de la página, empujando todo hacia abajo: 0.02

Análisis de INP: La herramienta de prueba de regex era la peor infractora. Cada pulsación de tecla en el input de regex desencadenaba:

  1. Un re-renderizado completo de todo el componente de la herramienta
  2. Evaluación del regex contra la cadena de prueba
  3. Resaltado de sintaxis del patrón regex

Tiempo total por pulsación de tecla: 280-400ms.

Las correcciones#

Semana 1: LCP y CLS

  1. Reemplacé el CDN de Google Fonts con next/font. La fuente ahora está alojada localmente, cargada en tiempo de build, con fallback ajustado en tamaño. CLS por fuentes: 0.06 → 0.00

  2. Eliminé el CSS de la librería de componentes. Reescribí los 3 componentes que estaba usando con Tailwind. CSS total eliminado: 180KB. CSS bloqueante del renderizado: eliminado

  3. Agregué revalidate = 3600 a la página de herramientas y las páginas de detalle de herramientas. El primer hit se renderiza en el servidor, los siguientes se sirven desde caché. TTFB: 420ms → 45ms (cacheado)

  4. Agregué dimensiones explícitas a todos los componentes de tarjetas de herramientas y usé aspect-ratio para layouts responsivos. CLS por tarjetas: 0.04 → 0.00

  5. Moví el banner de cookies a position: fixed en la parte inferior de la pantalla. CLS por banner: 0.02 → 0.00

Semana 2: INP

  1. Envolví el cómputo de resultados del probador de regex en startTransition:
tsx
function RegexTester() {
  const [pattern, setPattern] = useState("");
  const [testString, setTestString] = useState("");
  const [results, setResults] = useState<RegexResult[]>([]);
  const [isPending, startTransition] = useTransition();
 
  function handlePatternChange(value: string) {
    setPattern(value); // Urgent: update the input
 
    startTransition(() => {
      // Non-urgent: compute matches
      try {
        const regex = new RegExp(value, "g");
        const matches = [...testString.matchAll(regex)];
        setResults(
          matches.map((m) => ({
            match: m[0],
            index: m.index ?? 0,
            groups: m.groups,
          }))
        );
      } catch {
        setResults([]);
      }
    });
  }
 
  return (
    <div>
      <input
        value={pattern}
        onChange={(e) => handlePatternChange(e.target.value)}
        className={isPending ? "opacity-70" : ""}
      />
      {/* results rendering */}
    </div>
  );
}

INP en el probador de regex: 380ms → 85ms

  1. Agregué debouncing al manejador de entrada del formateador JSON (300ms de retraso). INP en el formateador JSON: 260ms → 60ms

  2. Moví el cómputo del generador de hash a un Web Worker. El hashing SHA-256 de entradas grandes ahora ocurre fuera del hilo principal por completo. INP en el generador de hash: 200ms → 40ms

Los resultados#

Después de dos semanas de optimización (datos CrUX, móvil, percentil 75):

  • LCP: 3.8s → 1.8s (53% de mejora)
  • CLS: 0.12 → 0.00 (100% de mejora)
  • INP: 340ms → 110ms (68% de mejora)

Puntuación de Lighthouse: 62 → 97.

Las tres métricas sólidamente en el rango "Bueno". La página se siente instantánea en móvil. Y el tráfico orgánico de búsqueda aumentó un 12% en el mes siguiente a las mejoras (aunque no puedo probar causalidad — otros factores estaban en juego).

La checklist#

Si no te llevas nada más de este post, aquí está la checklist que ejecuto en cada proyecto:

LCP#

  • Identificar el elemento LCP con DevTools
  • Agregar priority (o fetchPriority="high") a la imagen LCP
  • Precargar recursos LCP en <head>
  • Eliminar CSS bloqueante del renderizado
  • Alojar fuentes localmente con next/font
  • Habilitar compresión Brotli/Gzip
  • Usar generación estática o ISR donde sea posible
  • Establecer cabeceras de caché agresivas para assets estáticos

CLS#

  • Todas las imágenes tienen width y height explícitos
  • Usando next/font con fallbacks ajustados en tamaño
  • El contenido dinámico usa position: fixed/absolute o espacio reservado
  • Las pantallas skeleton coinciden con las dimensiones reales del componente
  • Sin inyección de contenido en la parte superior después de la carga

INP#

  • Sin tareas largas (>50ms) durante los manejadores de interacción
  • Actualizaciones de estado no urgentes envueltas en startTransition
  • Manejadores de entrada con debounce (300ms)
  • Cómputo pesado delegado a Web Workers
  • Tamaño del DOM por debajo de 1,500 nodos donde sea posible

General#

  • Scripts de terceros cargados después de que la página sea interactiva
  • Tamaño del bundle analizado y con tree-shaking
  • CSS no utilizado eliminado
  • Imágenes servidas en formato AVIF/WebP
  • Monitoreo de usuarios reales en producción (librería web-vitals)

Reflexiones finales#

La optimización del rendimiento no es una tarea de una sola vez. Es una disciplina. Cada nueva funcionalidad, cada nueva dependencia, cada nuevo script de terceros es una posible regresión. Los sitios que se mantienen rápidos son aquellos donde alguien está vigilando las métricas continuamente, no aquellos donde alguien hizo un sprint de optimización de una sola vez.

Configura el monitoreo de usuarios reales. Configura alertas cuando las métricas retrocedan. Haz del rendimiento una parte de tu proceso de code review. Cuando alguien agrega una librería de 200KB, pregunta si hay una alternativa de 5KB. Cuando alguien agrega un cómputo síncrono en un manejador de eventos, pregunta si se puede diferir o mover a un worker.

Las técnicas en este post no son teóricas. Son lo que realmente hice, en este sitio, con números reales que las respaldan. Tu experiencia variará — cada sitio es diferente, cada audiencia es diferente, cada infraestructura es diferente. Pero los principios son universales: carga menos, carga de forma más inteligente, no bloquees el hilo principal.

Tus usuarios no te enviarán una nota de agradecimiento por un sitio rápido. Pero se quedarán. Volverán. Y Google lo notará.

Artículos relacionados