wandres.dev
IMÁGENES II · Responsive, lazy y placeholders

loading=lazy: el umbral de distancia, la regla del LCP y las trampas

Qué hace exactamente el atributo nativo de carga diferida, cuál es el umbral real de distancia en cada navegador, por qué nunca debe tocar el elemento del LCP y qué pasa con el escáner de precarga.

⏱ 18 min

loading="lazy" es una de las optimizaciones con mejor relación entre esfuerzo y resultado que existe: nueve caracteres que pueden quitar dos megas de la carga inicial de un listado largo. También es una de las que más daño hace mal aplicada, porque el mismo atributo puesto sobre la imagen equivocada añade cientos de milisegundos al LCP y esa regresión no aparece en ninguna auditoría automática que mire solo el peso total transferido.

🎯 Al terminar esta lección sabrás
  • Explicar en qué momento exacto del ciclo de vida decide el navegador cargar una imagen diferida.
  • Justificar por qué el umbral de distancia no es cero y cuánto vale en la práctica.
  • Identificar qué imágenes de una página nunca deben llevar lazy.
  • Reconocer las tres trampas que convierten la carga diferida en una regresión.

Qué hace el navegador cuando ve lazy

El atributo tiene dos valores: eager, que es el comportamiento por defecto y significa «descarga ya», y lazy, que significa «aplaza la descarga hasta que se cumpla una condición de proximidad al viewport».

La secuencia interna importa porque explica todas las trampas. Cuando el escáner de precarga encuentra un <img loading="lazy">, no arranca la descarga. Registra el elemento y sigue. Más tarde, cuando el motor hace el primer layout y sabe dónde cae ese elemento en el documento, calcula su distancia al viewport actual. Si esa distancia es menor que un umbral, dispara la descarga; si no, lo pone bajo vigilancia y lo reevalúa en cada desplazamiento y en cada cambio de layout.

De ahí sale la primera consecuencia estructural: una imagen diferida siempre se descarga más tarde que una ansiosa, incluso si está en la primera pantalla. Ha perdido el escaneo de precarga y tiene que esperar al layout. En una página con CSS pesado, ese retraso son fácilmente 300 o 500 milisegundos en un móvil. Nueve caracteres, medio segundo.

La segunda consecuencia es que el atributo depende del layout, y por tanto una imagen dentro de un contenedor sin altura definida puede acabar «apilada» en la posición cero junto a todas las demás. Si veinte imágenes diferidas creen estar todas en la coordenada vertical cero porque sus contenedores todavía no tienen altura, el navegador las considera todas visibles y las descarga todas de golpe. La carga diferida se anula sola. La cura es la misma que la del CLS: reservar espacio con width, height o aspect-ratio.

El umbral de distancia, y por qué no es cero

Si el navegador esperase a que la imagen entrara literalmente en el viewport, el usuario vería un hueco vacío durante todo el tiempo de descarga. Con una imagen de 60 KB y una latencia de 150 milisegundos, el hueco dura medio segundo. Inaceptable. Por eso todos los navegadores usan un margen de anticipación.

Chromium arrancó en 2019 con un umbral muy conservador de 3000 píxeles en conexión rápida y 4000 en conexión lenta, y lo redujo poco después a 1250 píxeles en conexiones rápidas y 2500 en conexiones lentas (3G o peor), tras comprobar que con esos valores las imágenes seguían llegando a tiempo pero se ahorraban muchos más bytes. Esos umbrales se aplican en el eje del desplazamiento, y son deliberadamente asimétricos: el navegador anticipa hacia donde el usuario va a ir.

El número tiene una lectura práctica que a mucha gente le sorprende: en un móvil de 800 píxeles de alto, un umbral de 1250 significa que se precargan aproximadamente una pantalla y media por delante. Es decir, en un listado de tarjetas, lazy no ahorra nada en las tres o cuatro primeras filas. Solo empieza a rendir a partir de la quinta. Si tu página tiene ocho imágenes en total, el atributo probablemente no cambia nada medible; si tiene doscientas, cambia la vida.

Los otros motores usan umbrales propios que no publican con la misma precisión y que no coinciden con los de Chromium. No escribas código que dependa de un valor concreto del umbral: es una decisión de implementación, no una garantía de la especificación, y ha cambiado ya al menos una vez.

La regla que no se negocia: nunca sobre el LCP

El elemento que produce el LCP suele ser una imagen: la de la cabecera, la del producto, la del artículo. Poner loading="lazy" en esa imagen es la forma más rápida de arruinar la métrica, y ocurre constantemente porque alguien decide aplicar el atributo «a todas las imágenes» desde un componente compartido o desde un plugin.

El daño es acumulativo, no lineal. La imagen pierde el escaneo de precarga, espera al layout, y además arranca con prioridad baja porque las imágenes fuera del viewport se piden en la cola de baja prioridad. En un móvil con red mediocre eso son entre 500 y 1500 milisegundos añadidos a la única métrica de carga que Google usa para clasificar.

La regla operativa es de tres líneas y hay que aplicarla en el componente, no a mano:

<!-- La imagen del hero: ansiosa y con prioridad alta -->
<img src="hero-1200.avif" srcset="hero-800.avif 800w, hero-1200.avif 1200w, hero-1800.avif 1800w"
     sizes="100vw" width="1800" height="900" alt="Taller de encuadernación"
     fetchpriority="high" decoding="async">

<!-- Todo lo que está por debajo del pliegue -->
<img src="paso-600.avif" srcset="paso-400.avif 400w, paso-600.avif 600w, paso-900.avif 900w"
     sizes="(min-width: 700px) 600px, 100vw" width="900" height="600" alt="Cosido a mano del cuadernillo"
     loading="lazy" decoding="async">

En un componente de imagen reutilizable, la forma correcta es que el consumidor tenga que declarar explícitamente si la imagen está sobre el pliegue, con un valor por defecto seguro. Y si tu marco te da un componente de imagen que aplica lazy por defecto a todo, búscale la opción de prioridad antes de usarlo en una cabecera.

Diferir un iframe es otra cosa, y diferir por debajo del pliegue puede empeorar el INP

Dos matices que separan al que ha medido esto del que lo ha leído.

El primero: loading="lazy" también existe en <iframe>, y ahí el ahorro es de otro orden de magnitud. Un iframe de vídeo incrustado o de mapa arrastra su propio documento, su propio JavaScript y sus propias conexiones: entre 400 KB y 1,5 MB, y varios cientos de milisegundos de hilo principal. Diferirlo es probablemente la optimización más rentable que le puedes hacer a una página de contenido con incrustaciones. Aun así, la solución completa no es diferirlo sino sustituirlo por una fachada estática que solo carga el iframe real cuando el usuario hace clic, porque un iframe diferido que entra en el viewport sigue costando lo mismo cuando llega.

El segundo, y es el que casi nadie ve: la carga diferida traslada trabajo del arranque al momento del desplazamiento, y el momento del desplazamiento es exactamente cuando el usuario está interactuando. Cada imagen que entra en el umbral dispara una petición, una decodificación y un pintado en un instante en que el hilo principal debería estar libre. Con veinte imágenes diferidas de 200 KB en un scroll rápido, verás en el perfil una sucesión de tareas de decodificación que compiten con el desplazamiento. El síntoma es un desplazamiento a tirones y un INP degradado en móviles de gama baja, con un LCP impecable. Si te encuentras eso, la respuesta es decoding="async" en todas ellas, reducir el peso de cada candidato, y considerar si de verdad hacen falta veinte imágenes o bastan seis y un botón de cargar más.

Las trampas que quedan

Trampa uno: el background-image de CSS no admite lazy. No hay atributo. Una imagen de fondo se descarga cuando el motor determina que su elemento genera cajas y es visible, lo cual es una forma implícita y poco controlable de diferir. Si necesitas control, usa <img> con object-fit: cover en lugar de un fondo. Es además mejor para accesibilidad y para el LCP, porque una imagen de fondo puede ser candidata a LCP pero se descubre más tarde que un <img>.

Trampa dos: lazy no cancela nada. Una vez que la descarga arranca, arranca. Si el usuario baja doscientos píxeles y vuelve a subir, la imagen ya se está bajando. No hay marcha atrás y no hay que esperarla.

Trampa tres: las herramientas de auditoría no penalizan el exceso. Una página con lazy en las diez imágenes de la primera pantalla transfiere menos bytes en la carga inicial y puede sacar mejor nota en algunas comprobaciones, mientras el LCP real en campo empeora. Los datos de campo y los de laboratorio discrepan precisamente aquí. Fíate del percentil 75 de usuarios reales, no de la nota.

Y una comprobación de dos segundos que ahorra disgustos: si tu página tiene menos de una docena de imágenes y todas caben en dos pantallas, no pongas lazy en ninguna. El atributo es una optimización para volumen, y por debajo de cierto volumen solo introduce latencia.

⚔️ Reto práctico

Abre un listado largo de tu sitio con el panel de red, filtra por imágenes y ordena por hora de inicio. Cuenta cuántas se piden antes del primer desplazamiento. Después mide la altura del viewport, súmale 1250 y comprueba si el número de imágenes precargadas coincide con las que caen dentro de esa distancia. Si se piden todas, busca qué contenedor no tiene altura reservada.