Saltar al contenido

Core Web Vitals en Next.js: qué mueve de verdad los números

LCP, INP y CLS tienen causas concretas en una aplicación Next.js. Esta es la lista ordenada que recorremos en los proyectos de rendimiento, con los cambios que sí pagan.

5 min de lectura

Una puntuación de Lighthouse es una medición de laboratorio de una carga de página en un dispositivo simulado. Los datos de campo son lo que tus visitantes experimentaron de verdad, y son lo que usa Google. Empieza siempre por ahí:

# p75 field data for an origin, from the Chrome UX Report
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"origin":"https://example.com","formFactor":"PHONE"}'

Si tu puntuación de laboratorio es 95 y tu LCP de campo es 3,8 s, el laboratorio te está mintiendo sobre algo — normalmente una fuente, un script de terceros, o una red real que la simulada no está modelando.

LCP: casi siempre es la imagen o la fuente

Largest Contentful Paint mide cuándo termina de pintarse el elemento más grande visible sin desplazar. En una aplicación Next.js, cuatro causas cubren la gran mayoría de los fallos.

1. La imagen hero no está priorizada

import Image from 'next/image';
 
<Image
  src="/hero.jpg"
  alt=""
  width={1200}
  height={630}
  priority          // preloads it; without this it waits in the lazy queue
  sizes="100vw"
  quality={80}
/>

Sin priority, tu elemento LCP se queda detrás de todos los demás recursos en la cola del navegador. Es un cambio de una línea que habitualmente quita medio segundo al LCP móvil.

Igualmente: poner priority en más de una o dos imágenes es el mismo error al revés — si todo se precarga, nada está priorizado.

2. La ruta es dinámica sin necesitarlo

Una ruta renderizada estáticamente sirve HTML desde el nodo edge más cercano. Una dinámica ejecuta primero tu árbol de componentes y tu base de datos. Si el tiempo hasta el primer byte supera los 600 ms, ninguna optimización de imagen salvará el LCP — el navegador todavía no ha recibido nada que pintar.

Esa es la conexión entre estrategia de renderizado y Core Web Vitals que la mayoría de las listas de comprobación de rendimiento se saltan por completo.

3. Las fuentes bloquean el pintado

import { Inter } from 'next/font/google';
 
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',      // paint immediately with the fallback
  preload: true,
  adjustFontFallback: true,  // reduces the shift when the real font arrives
});

next/font aloja el fichero en tu propio origen, lo que elimina una resolución DNS, un handshake TLS y un viaje de ida y vuelta a un origen de terceros de tu camino crítico. Si aún cargas fuentes con un <link> a un CDN de fuentes, ahí tienes una mejora gratis sobre la mesa.

4. Hay un script de terceros en el head

Gestores de etiquetas, widgets de chat, banners de consentimiento, grabadores de sesión. Cada uno es una petición bloqueante en el camino crítico salvo que se difiera explícitamente:

import Script from 'next/script';
 
<Script src="https://example.com/widget.js" strategy="lazyOnload" />

En las auditorías, el mayor contribuyente individual al LCP es un script de terceros aproximadamente un tercio de las veces. Conviene medirlo antes de gastar un sprint en tu propio código.

INP: el coste de la hidratación

Interaction to Next Paint sustituyó a First Input Delay porque FID medía lo que no tocaba — cronometraba solo el retraso antes de que arrancara el manejador, no cuánto esperó la visitante para ver un resultado.

Los problemas de INP en Next.js son problemas de bundle. Las causas, en el orden en que las encontramos:

  • Un límite de cliente demasiado arriba en el árbol. Lo tratamos a fondo en nuestro artículo sobre dónde te cuesta de verdad use client.
  • Una dependencia pesada importada en el ámbito del módulo. Librerías de gráficos, de fechas, editores. Cárgalas con next/dynamic y ssr: false cuando el componente esté realmente por debajo del pliegue.
  • Trabajo caro en un manejador de eventos. Filtrar diez mil filas en un onChange. Llévalo al servidor, o aplica debounce y virtualiza.
  • Demasiadas raíces de hidratación. Cien tarjetas interactivas independientes en una página de listado son cien unidades de hidratación compitiendo por el hilo principal.
import dynamic from 'next/dynamic';
 
const Chart = dynamic(() => import('@/components/Chart'), {
  ssr: false,
  loading: () => <ChartSkeleton />,
});

CLS: reserva el espacio

Cumulative Layout Shift es el más fácil de arreglar de los tres y el más vergonzoso de publicar.

  • Da siempre width y height explícitos a las imágenes, o envuélvelas en un contenedor con aspect-ratio fijo. next/image lo impone, que es una de las mejores razones para usarlo.
  • Reserva espacio para todo lo que llega tarde — huecos publicitarios, incrustaciones, banners de consentimiento. Un esqueleto con las dimensiones del elemento final no cuesta nada y elimina el desplazamiento por completo.
  • Usa font-display: swap junto con adjustFontFallback para que las métricas de la fuente de reserva se aproximen a las de la real.
  • Nunca inyectes un banner encima del contenido existente después de cargar. Superponlo, o reserva el espacio en el HTML inicial.

Defender el resultado

Cada arreglo de arriba se degrada. Alguien añade una dependencia, alguien añade un script, alguien mueve un límite. Sin un mecanismo que lo imponga, volverás a hacer este trabajo el año que viene.

# .github/workflows/performance.yml
- name: Lighthouse CI
  uses: treosh/lighthouse-ci-action@v12
  with:
    urls: |
      https://staging.example.com/
      https://staging.example.com/pricing
    budgetPath: ./lighthouse-budget.json
    uploadArtifacts: true
[
  {
    "path": "/*",
    "resourceSizes": [
      { "resourceType": "script", "budget": 180 },
      { "resourceType": "total", "budget": 600 }
    ],
    "timings": [{ "metric": "interactive", "budget": 3000 }]
  }
]

Una pull request que empuja el presupuesto de JavaScript por encima de 180 KB ahora hace fallar una comprobación en lugar de publicarse en silencio. Ese fichero de presupuesto es el entregable real de un proyecto de rendimiento. La puntuación mejorada es solo lo que ocurre de camino a tenerlo.

El orden en que trabajamos

  1. Conseguir datos de campo. Segmentar por clase de dispositivo y plantilla de ruta.
  2. Arreglar la estrategia de renderizado — una ruta dinámica que debería ser estática gana a todo lo demás de esta lista.
  3. Arreglar el elemento LCP: prioridad, dimensiones, formato.
  4. Pasar las fuentes a next/font y sacar los scripts de terceros del camino crítico.
  5. Bajar el límite de cliente; dividir en código lo que quede.
  6. Reservar espacio para todo lo que llegue tarde.
  7. Poner un presupuesto en CI para que nada de lo anterior pueda retroceder.

Los pasos dos y tres suelen ser la mayor parte de la ganancia. El resto es conservarla.

Volver a todos los artículos