wandres.dev
OPTIMIZAR LCP · El elemento más grande

Eliminar el retraso de renderizado

El tramo entre que el recurso ya está en el dispositivo y el elemento aparece en pantalla: sus cuatro causas, el criterio de tamaño de la hoja de estilos, y el antiparpadeo que cuesta segundos.

⏱ 20 min

Hay un estado de la carga especialmente frustrante: la imagen ya está descargada, entera, en el dispositivo, y la pantalla sigue en blanco. Ese hueco es el retraso de renderizado, su objetivo es cero, y cuando no lo es la causa nunca está en la imagen. Está en algo que bloquea el pintado o en algo que aún no ha metido el elemento en el documento.

🎯 Al terminar esta lección sabrás
  • Enumerar las cuatro causas del retraso de renderizado y asociarlas a su síntoma.
  • Aplicar el criterio de tamaño relativo entre la hoja de estilos y el recurso del LCP.
  • Cuantificar el coste de un fragmento antiparpadeo de test A/B mal configurado.
  • Medir el tramo con el desglose y confirmar la mejora.

Las cuatro causas

La guía oficial las enumera y cada una tiene un síntoma reconocible en un perfil de rendimiento.

Uno: el renderizado de toda la página está bloqueado. Una hoja de estilos o un script síncrono en la cabecera que todavía se está descargando. Nada se pinta, ni el elemento LCP ni ninguna otra cosa. Síntoma: el primer pintado con contenido también llega tarde, y la distancia entre él y el LCP es pequeña. Es el caso desarrollado en el CSS bloqueante y el JS bloqueante.

Dos: el elemento todavía no está en el DOM. El recurso se descargó porque alguien lo precargó o porque el escáner lo vio, pero el nodo que lo va a usar no existe hasta que se ejecute cierto JavaScript. Síntoma: el primer pintado llega pronto, el LCP mucho después, y en la traza hay una tarea larga de ejecución justo antes del pintado del elemento.

Tres: algo lo está ocultando. Una librería de tests A/B que aún no ha decidido qué variante toca, una animación de entrada con opacidad cero, un contenedor con visibilidad oculta hasta que se resuelve una promesa. Un elemento con opacidad cero no es candidato a LCP, así que el reloj sigue corriendo mientras la imagen ya está ahí, completa e invisible.

Cuatro: el hilo principal está ocupado. Todos los navegadores actuales pintan las imágenes en el hilo principal, así que una tarea larga de JavaScript sin relación ninguna con la imagen retrasa su aparición. Síntoma: el perfil muestra la descarga terminada y un bloque compacto de ejecución antes del fotograma en que aparece.

El criterio del tamaño relativo

Este es el más accionable de todos y casi nadie lo formula así.

Una hoja de estilos bloqueante y el recurso del elemento LCP se descargan en paralelo. El elemento no puede pintarse hasta que las dos cosas terminen. Por lo tanto:

Si tu hoja de estilos tarda más en llegar que la imagen del hero, la hoja de estilos es tu LCP.

De ahí sale un criterio de presupuesto directo: el CSS bloqueante tiene que ser más pequeño que el recurso del elemento principal. No “pequeño” en abstracto: más pequeño que esa imagen concreta, medido en bytes transferidos tras compresión.

Con la conexión de referencia de 200 KB por segundo:

Recurso Tamaño transferido Tiempo
Imagen del hero en AVIF 60 KB 300 ms
CSS bloqueante 180 KB 900 ms

La imagen está lista a los 300 ms y el elemento aparece a los 900. Seiscientos milisegundos de retraso de renderizado producidos íntegramente por una hoja de estilos, y ninguna optimización de la imagen los va a tocar.

Las tres salidas, en orden:

Reducir el CSS bloqueante. Eliminar reglas no usadas, separar lo crítico de lo diferido, y comprimir bien. El panel de cobertura de las herramientas de desarrollo da el porcentaje de CSS no utilizado en la primera pantalla, que en sitios con un framework de utilidades sin purgar supera con frecuencia el noventa por ciento.

Incrustar el CSS crítico en el documento. Elimina la petición entera, y por tanto el paralelismo deja de importar. Solo compensa si es pequeño: lo incrustado no se cachea entre navegaciones, así que lo pagas en cada visita. Si tu hoja de estilos es tan grande que tarda más que la imagen, no es candidata a incrustarse.

Cargar de forma diferida lo no crítico. El patrón de la precarga que se convierte en hoja de estilos al terminar de cargar, que descarga sin bloquear y aplica al final:

<link rel="preload" as="style" href="/css/secundario.css"
      onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/secundario.css"></noscript>

El antiparpadeo de los tests A/B

Merece apartado propio porque es la causa individual que más retraso de renderizado produce en sitios por lo demás bien construidos, y porque su coste está escrito en la propia configuración.

Las herramientas de experimentación insertan un fragmento que oculta el cuerpo del documento hasta que la librería decide qué variante mostrar, para que el usuario no vea primero la versión original y después el cambio. Ese fragmento lleva un tiempo máximo de espera, y el valor por defecto habitual está entre 1.000 y 4.000 milisegundos.

Mientras el cuerpo está oculto, nada es candidato a LCP. Si la librería tarda 1.200 ms en cargar y decidir, tu LCP es como mínimo 1.200 ms, aunque toda tu página estuviera lista a los 400.

<!-- El patron habitual. El estilo se aplica de inmediato y el
     temporizador decide cuanto tiempo puede durar el bloqueo. -->
<style>.antiparpadeo { opacity: 0 !important; }</style>
<script>
  document.documentElement.classList.add('antiparpadeo');
  setTimeout(quitar, 1000);
</script>

Las tres correcciones, de menos a más ambiciosa:

Bajar el tiempo máximo. De 4.000 a 500 ms es un cambio de una línea y acota el daño en el peor caso.

Reducir el alcance. Ocultar solo el contenedor que participa en el experimento, no el documento entero. Si el experimento cambia un botón, no hay motivo para esconder el hero.

Aplicar las variantes en el servidor o en el borde. Sin ocultación, sin parpadeo y sin retraso. Es la solución correcta y exige coordinación con quien monta los experimentos, que normalmente no sabe que su valor por defecto cuesta un segundo de métrica.

⚠️
Una animación de entrada sobre el elemento LCP retrasa la métrica exactamente lo que dure

Un hero que aparece con una transición de opacidad de cero a uno durante 600 ms no está renderizado a efectos de la métrica hasta que deja de ser invisible. Es una decisión de diseño legítima que cuesta métrica, y merece la pena que quien la toma lo sepa. Si la animación es innegociable, arráncala desde una opacidad distinta de cero, o anima la posición en lugar de la opacidad: un elemento desplazado sí cuenta como renderizado.

Las tareas largas y el pintado

El cuarto caso es el menos intuitivo porque no hay relación causal aparente: un script de analítica que tarda 300 ms en ejecutarse retrasa la aparición de una imagen que ya está descargada.

El mecanismo es que el pintado ocurre en el hilo principal, y el hilo principal atiende una tarea cada vez. Mientras el analizador ejecuta esos 300 ms, el navegador no puede producir un fotograma, así que la imagen esperará su turno.

// Que tareas largas hubo antes del LCP.
const largas = [];
new PerformanceObserver((l) => largas.push(...l.getEntries()))
  .observe({ type: 'longtask', buffered: true });

new PerformanceObserver((l) => {
  const e = l.getEntries();
  const lcp = e[e.length - 1].startTime;
  const antes = largas.filter((t) => t.startTime < lcp);
  console.log('tareas largas antes del LCP:', antes.length,
    '| total', Math.round(antes.reduce((s, t) => s + t.duration, 0)) + 'ms');
  antes.forEach((t) => console.log('  ', Math.round(t.startTime) + 'ms',
    Math.round(t.duration) + 'ms', t.attribution[0]?.containerType || ''));
}).observe({ type: 'largest-contentful-paint', buffered: true });

Si la suma de tareas largas antes del LCP es de varios cientos de milisegundos, tienes ahí una parte del retraso de renderizado, y el remedio no está en el hero: está en el JavaScript que se ejecuta antes de que la página termine de cargar.

Un último recordatorio sobre el texto: si tu elemento LCP es un bloque de texto que usa una fuente web, el navegador no lo considera renderizado durante el periodo de bloqueo de la fuente. Con el comportamiento por defecto, ese periodo puede ser de hasta tres segundos, y durante ese tiempo tu métrica está esperando a un archivo tipográfico. Cualquier valor de font-display distinto del bloqueo hace que el texto se pinte con la fuente de reserva y la métrica deje de depender de esa descarga.

El retraso de renderizado es el único tramo donde optimizar el recurso empeora tu diagnóstico

Los otros tres tramos tienen una propiedad tranquilizadora: si los reduces, el LCP baja. El retraso de renderizado no, y por un motivo que ya vimos en las cuatro subpartes: cuando este tramo es el cuello de botella, absorbe cualquier mejora que hagas en los otros. Comprimes la imagen a la mitad y el LCP no se mueve; adelantas el descubrimiento trescientos milisegundos y el LCP no se mueve. Lo que ocurre es que el elemento se pinta cuando lo permite el bloqueo, no cuando el recurso está listo, así que estás rellenando un hueco de espera con más espera. La consecuencia operativa es importante y contraintuitiva: este tramo hay que mirarlo primero, aunque su porcentaje objetivo sea el más pequeño, porque mientras esté inflado ninguna otra medición te va a dar una señal fiable. Y la comprobación de que lo has arreglado no es que el LCP baje inmediatamente, sino que las optimizaciones que antes no movían el número empiecen a moverlo. He visto equipos abandonar un trabajo perfectamente correcto de optimización de imágenes porque no producía efecto, sin darse cuenta de que el efecto estaba secuestrado por un fragmento antiparpadeo de mil milisegundos que nadie había mirado en dos años.