Saltar al contenido

next/image no va a arreglarte el LCP

El componente optimiza bytes. El LCP lo decide cuándo empieza la petición, y las causas habituales son el orden de descubrimiento, un hero en lazy y una fuente que bloquea el pintado.

6 min de lectura

Llegan equipos que ya han cambiado cada <img> por next/image, y el LCP no se ha movido. Es el resultado esperable, y merece la pena entender por qué, porque el mismo razonamiento señala lo que sí habría funcionado.

next/image optimiza la carga: un formato más pequeño, las dimensiones adecuadas para el dispositivo, sin desplazamiento de layout. El LCP mide el momento del pintado más grande. Los bytes son una entrada de eso, y normalmente no la que manda.

Qué decide cuándo empieza la petición

El navegador solo puede pedir lo que ha descubierto. Para la imagen del hero hay tres posibilidades, y están separadas por un orden de magnitud:

Cómo se encuentra la imagenCuándo empieza la petición
<img> en el HTML inicial, eagerEn el escaneo de precarga, antes del CSS
<img> en el HTML inicial, lazyTras el layout, cuando el navegador sabe que está a la vista
Insertada por JavaScript tras la hidrataciónCuando el bundle se descarga, se parsea y se ejecuta

next/image carga en lazy por defecto. Así que la migración típica convierte un hero eager en uno lazy, retrasa su petición hasta después del layout y empeora el LCP mientras todas las auditorías de imagen de Lighthouse se ponen verdes.

El arreglo es una prop:

import Image from 'next/image';
 
export function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="..."
      width={1600}
      height={900}
      priority          // eager + fetchpriority="high" + una pista de precarga
      sizes="100vw"
      className="w-full h-auto"
    />
  );
}

priority en exactamente una imagen por ruta: la que es el elemento LCP. Ponerla en seis imágenes equivale a no ponerla en ninguna, porque todas compiten por la misma conexión.

El error con sizes que cuesta más que el formato

sizes le dice al navegador qué candidato de srcset elegir, y se evalúa antes del layout. Si te equivocas, descargas una imagen de 1600px para un hueco de 400px o -peor para la calidad- una de 400px para un banner a sangre.

El valor por defecto al omitirlo es 100vw, que es correcto para un hero a todo ancho e incorrecto para todo lo demás:

// Imagen de tarjeta en una rejilla de tres columnas.
<Image
  src={post.cover}
  alt=""
  width={800}
  height={450}
  sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
/>

Ese único atributo suele valer más que la conversión a AVIF, porque cambia qué fichero se pide en lugar de cuántos bytes tiene ese fichero.

Cuando el elemento LCP es texto

En un sitio de contenido normalmente lo es, y entonces el trabajo con imágenes no viene al caso. El titular no puede pintarse hasta que se resuelve su fuente, así que la cadena es HTML → CSS → fichero de fuente → pintado.

import { Inter } from 'next/font/google';
 
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',   // pinta en la de respaldo y cambia cuando llega el fichero
  variable: '--font-sans',
});

next/font aloja el fichero en tu propio dominio y emite una precarga, lo que saca la conexión de terceros de la cadena. display: 'swap' es lo que evita que el titular espere: el precio es un cambio visible, que se reduce eligiendo una fuente de respaldo con métricas parecidas, no bloqueando el pintado.

La otra mitad es la pila de respaldo. font-family: var(--font-sans), sans-serif deja que el navegador pinte de inmediato con una fuente cuyas métricas no elegiste; nombrar una del sistema que se parezca hace el cambio mucho menos visible.

Medir lo que toca

Lighthouse reporta LCP de laboratorio en una carga fría y con el ancho de banda limitado. Los datos de campo reportan lo que vivieron tus visitantes, con aciertos de caché y dispositivos reales. Se contradicen constantemente, y el número de campo es el que cuenta.

'use client';
 
import { useReportWebVitals } from 'next/web-vitals';
 
export function Vitals() {
  useReportWebVitals((metric) => {
    if (metric.name !== 'LCP') return;
    // El elemento, no solo el número: "el LCP es 3,1 s" no es accionable,
    // "el LCP es 3,1 s y es la imagen del hero" sí lo es.
    navigator.sendBeacon('/api/vitals', JSON.stringify({
      value: metric.value,
      element: metric.attribution?.element,
      url: location.pathname,
    }));
  });
 
  return null;
}

Registra el elemento. La mitad de los proyectos de rendimiento que hacemos empiezan descubriendo que el elemento LCP no es el que el equipo suponía: un banner de cookies, un <h1> oculto, un contenedor vacío que resulta ser grande.

El orden completo de qué arreglar y en qué secuencia está en Core Web Vitals en Next.js: qué mueve de verdad los números.

La versión corta

  1. Averigua qué elemento es el LCP, con datos de campo, ruta por ruta.
  2. Si es una imagen: priority en esa única imagen, sizes correcto, y asegúrate de que nada por encima en el HTML bloquee el parser.
  3. Si es texto: next/font con display: 'swap' y una fuente de respaldo con métricas cercanas.
  4. Solo después, piensa en formato y calidad.

Los pasos uno a tres son donde están los segundos. El paso cuatro es donde están las auditorías, que es justo por lo que se hace primero.

Volver a todos los artículos