wandres.dev
CORE WEB VITALS · LCP, INP y CLS

LCP con precisión: qué elementos son candidatos

La definición exacta del Largest Contentful Paint: qué elementos entran, cómo se calcula su tamaño, cuándo deja de reportarse y las diferencias entre la métrica y la API.

⏱ 18 min

El LCP parece obvio: el elemento más grande, cuándo aparece. Pero cada palabra de esa frase esconde una definición precisa. Qué cuenta como elemento, cómo se mide grande, y qué significa aparecer, son tres decisiones de especificación que explican la mitad de las sorpresas que da esta métrica. Esta lección las desarma una por una.

🎯 Al terminar esta lección sabrás
  • Enumerar exactamente qué tipos de elemento pueden ser candidatos a LCP y cuáles no.
  • Calcular el tamaño con el que compite un elemento según las reglas de la especificación.
  • Explicar en qué momento el navegador deja de emitir candidatos y por qué.
  • Distinguir lo que reporta la API de lo que define la métrica.

Qué elementos son candidatos

La lista es cerrada y está en la especificación del Largest Contentful Paint. Son candidatos:

  • Elementos <img>. Para contenido animado como GIF o PNG animado, se usa el tiempo de presentación del primer fotograma.
  • Elementos <image> que estén dentro de un <svg>.
  • Elementos <video>. Se usa el tiempo de carga de la imagen de póster o el tiempo de presentación del primer fotograma, lo que ocurra antes.
  • Elementos con una imagen de fondo cargada mediante la función url(). Los degradados de CSS no cuentan.
  • Elementos de nivel de bloque que contengan nodos de texto u otros hijos de texto de nivel en línea.

Y no son candidatos, entre otras cosas que la gente asume que sí:

  • Un <svg> completo. El logotipo de Instagram en SVG no es candidato a LCP, aunque un <img src="algo.svg"> sí lo es y un <image> dentro del SVG también.
  • Elementos con opacidad 0, invisibles para el usuario.
  • Elementos que cubren toda la ventana, que el navegador considera fondo y no contenido.
  • Imágenes de marcador de posición o de baja entropía, que probablemente no reflejan el contenido real de la página.

Las tres últimas exclusiones son heurísticas, no reglas de la especificación, y el navegador puede refinarlas con el tiempo. Su propósito es evitar que un fondo o un esqueleto se lleve la etiqueta de contenido principal.

⚠️
La restricción del conjunto de elementos es deliberada

El conjunto está limitado a propósito para reducir la complejidad de la implementación, no porque los elementos excluidos no importen. Es posible que se amplíe en el futuro, por ejemplo con soporte completo de <svg>. Mientras tanto, si tu contenido principal es un SVG en línea, el LCP señalará otra cosa y tendrás que medir ese elemento por tu cuenta con la API de Element Timing.

Cómo se calcula el tamaño

El tamaño con el que un elemento compite no es su tamaño intrínseco, es el que ocupa visiblemente. Las reglas concretas:

  • Solo cuenta la parte dentro de la ventana. Si un elemento se sale por abajo, la parte de fuera no suma.
  • Si el elemento está recortado o tiene desbordamiento no visible, la parte recortada no suma.
  • Para imágenes redimensionadas respecto a su tamaño intrínseco, se usa el menor entre el tamaño visible y el tamaño intrínseco. Esto es importante: una imagen de 4.000 píxeles de ancho mostrada a 400 compite con 400, no con 4.000. Y una imagen de 100 píxeles estirada a 800 compite con 100.
  • Para elementos de texto, se considera el rectángulo más pequeño que contiene todos los nodos de texto, no la caja del elemento.
  • No cuentan márgenes, rellenos ni bordes aplicados con CSS.

La regla del menor entre visible e intrínseco tiene una consecuencia práctica que sorprende: servir una imagen enorme reducida no te da ventaja en el LCP, solo te cuesta bytes. Y la regla del rectángulo de texto explica por qué a veces el elemento LCP es un párrafo aparentemente pequeño: un <div> con mucho relleno compite con el rectángulo de sus letras, no con su caja.

La asignación de nodos de texto a elementos también tiene su detalle. Cada nodo de texto pertenece al elemento que genera su bloque contenedor, es decir, a su ancestro de nivel de bloque más cercano. En un <p> con un <strong> dentro, todo el texto cuenta para el <p>.

Cuándo se reporta y cuándo deja de reportarse

Esta es la parte que más gente desconoce y la que más consecuencias tiene.

El navegador emite una entrada de tipo largest-contentful-paint en cuanto pinta el primer fotograma, señalando el candidato de ese momento. Después emite una entrada nueva cada vez que cambia el elemento más grande. Una página típica emite varias: primero el titular, luego la imagen del hero cuando termina de cargar.

Un elemento solo puede ser candidato después de haberse renderizado y ser visible. Una imagen que aún no ha cargado no está renderizada. Un texto que usa una fuente web durante su periodo de bloqueo tampoco lo está. Por eso puede reportarse primero un elemento pequeño y después uno grande.

Si el elemento más grande se elimina de la ventana, o incluso del DOM, sigue siendo el elemento más grande salvo que se renderice otro mayor. La métrica no retrocede.

Y aquí el punto clave: el navegador deja de emitir candidatos nuevos en cuanto el usuario interactúa con la página, mediante un toque, un desplazamiento o una pulsación de tecla. La razón es que una interacción suele cambiar lo que el usuario ve, y muy especialmente el desplazamiento.

Esa regla tiene dos consecuencias prácticas de peso. La primera es que una página con un elemento grande que carga tarde puede reportar un LCP artificialmente bueno si el usuario hace scroll antes de que llegue. La segunda es que el LCP que mides tú, que sabes dónde está todo y tocas la pantalla enseguida, casi siempre será mejor que el de un usuario que se queda mirando.

// Observar todos los candidatos y ver como cambia el elemento.
new PerformanceObserver((lista) => {
  for (const entrada of lista.getEntries()) {
    console.log(
      'candidato LCP a', Math.round(entrada.startTime), 'ms',
      '| tamano', entrada.size,
      '| elemento', entrada.element,
      '| url', entrada.url || 'sin recurso',
    );
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

El indicador buffered: true hace que el observador reciba también las entradas emitidas antes de registrarse, que es lo normal si tu script de medición carga con retraso. Sin ese indicador, perderías los primeros candidatos.

Diferencias entre la métrica y la API

La API no es la métrica. Hay cinco divergencias documentadas que hay que conocer si mides por tu cuenta.

  1. Páginas cargadas en segundo plano. La API emite entradas para pestañas abiertas en segundo plano; la métrica las descarta, porque no reflejan el tiempo de carga percibido.
  2. Páginas que pasan a segundo plano durante la carga. La API sigue emitiendo; la métrica solo considera elementos si la página estuvo en primer plano todo el tiempo.
  3. Restauración desde la caché de retroceso y avance. La API no emite entradas cuando la página se restaura desde esa caché; la métrica sí debe medirlas, porque el usuario lo vive como una visita distinta.
  4. Iframes. La API no considera elementos dentro de un iframe; la métrica sí, porque forman parte de la experiencia. Esta es la principal fuente de discrepancia entre los datos públicos del navegador y tu propia instrumentación: si tu elemento principal es la imagen de póster de un vídeo incrustado, tú no lo verás y el conjunto de datos público sí.
  5. Páginas prerrenderizadas. La API mide desde el inicio de la navegación; la métrica debe medirse desde activationStart, que es cuando el usuario realmente activó la página.

Hay una sexta sutileza de seguridad que merece mención. Durante años, el instante de renderizado de imágenes de otro origen no se exponía si faltaba la cabecera Timing-Allow-Origin: solo se exponía el instante de carga. Eso producía situaciones aparentemente imposibles, como un LCP anterior al FCP. La restricción se resolvió y desde Chrome 133 se expone un tiempo de renderizado ligeramente redondeado aunque falte la cabecera. Aun así, sigue mereciendo la pena enviar Timing-Allow-Origin desde tus dominios de imágenes: los datos son más precisos y otros navegadores pueden no incorporar todavía ese cambio.

Un LCP que mejora sin que hayas tocado nada es casi siempre un cambio de elemento, no una mejora

El error de interpretación más caro con esta métrica es leer una bajada del valor como una mejora. El LCP no mide la velocidad de un elemento fijo: mide cuándo aparece el que resulte ser el más grande, y ese elemento puede cambiar de una semana a otra por razones que no tienen nada que ver con el rendimiento. Un cambio de diseño que reduce el hero de 600 a 300 píxeles de alto puede hacer que el elemento LCP pase a ser el titular, que se pinta un segundo antes: tu gráfico baja un segundo y no has acelerado nada, la imagen sigue tardando lo mismo y el usuario la sigue esperando igual. Al revés también pasa: añades un banner promocional grande y tu LCP empeora medio segundo sin que nada se haya ralentizado. Por eso el dato de LCP hay que guardarlo siempre junto al selector del elemento y a la URL del recurso, que es exactamente lo que expone el build de atribución de la librería web-vitals. Sin esa dimensión, no puedes distinguir una regresión real de un cambio de diseño, y acabarás persiguiendo fantasmas o celebrando victorias inexistentes.