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

decoding y fetchpriority: los dos atributos que casi nadie pone

Qué hace realmente decoding con el hilo principal, cómo funciona la cola de prioridades del navegador para imágenes, y por qué fetchpriority=high en la imagen del hero suele valer más que comprimirla otra vez.

⏱ 17 min

Una imagen no cuesta solo lo que pesa. Cuesta también el lugar que ocupa en la cola de descargas del navegador y el tiempo de CPU que tarda en convertirse de bytes comprimidos a un mapa de píxeles. fetchpriority actúa sobre lo primero y decoding sobre lo segundo, y ninguno de los dos cambia un solo byte del fichero. Son ajustes de planificación, y por eso son de las pocas optimizaciones que se pueden aplicar sin regenerar nada.

🎯 Al terminar esta lección sabrás
  • Describir la prioridad implícita que el navegador asigna a una imagen y en qué momento la reevalúa.
  • Aplicar fetchpriority sobre la imagen del LCP y medir la diferencia.
  • Explicar qué hace decoding="async" y por qué su efecto se ve en el INP y no en el LCP.
  • Reconocer los casos en los que subir la prioridad de una imagen empeora la página.

La cola de prioridades que ya existe

El navegador no descarga los recursos en el orden en que aparecen. Los mete en una cola con prioridades y las asigna por tipo, por posición y por atributos. En Chromium las categorías son cinco —desde muy alta hasta muy baja— y para imágenes el comportamiento por defecto es este:

Situación de la imagen Prioridad inicial Después del layout
Cualquier <img> descubierta por el escáner Baja Se reevalúa
Dentro del viewport inicial Baja Sube a alta
Fuera del viewport Baja Se queda baja
Con loading="lazy" No se pide Se pide en baja al acercarse

La fila que importa es la segunda. Una imagen visible arranca en prioridad baja y solo sube a alta después del primer layout. Ese es el hueco que fetchpriority viene a tapar: durante los primeros cientos de milisegundos, la imagen que va a determinar tu LCP compite en la cola de baja prioridad con todas las demás imágenes de la página, incluidas las del pie. Chromium limita el número de descargas simultáneas de baja prioridad mientras el documento no ha terminado de parsearse, así que tu imagen del hero puede estar literalmente esperando su turno detrás de un icono decorativo.

fetchpriority="high" corrige eso en el instante del escaneo de precarga, antes de que exista layout:

<img src="hero-1400.avif"
     srcset="hero-900.avif 900w, hero-1400.avif 1400w, hero-2000.avif 2000w"
     sizes="100vw" width="2000" height="1000"
     alt="Fachada del mercado central al atardecer"
     fetchpriority="high" decoding="async">

El atributo acepta high, low y auto, y funciona sobre <img>, <link>, <script> e <iframe>, además de existir como opción priority en fetch(). Está disponible en Chromium desde la versión 101, en Safari desde 17.2 y en Firefox desde la 132, de octubre de 2024. En un navegador que no lo entienda, el atributo se ignora sin efectos secundarios y la heurística por defecto sigue funcionando: es seguro ponerlo sin detección de características ni respaldo.

La mejora típica en el LCP de una página cuyo elemento mayor es una imagen sobre el pliegue está entre 100 y 500 milisegundos en condiciones de red móvil, y sube mucho más si la página tiene muchas imágenes compitiendo. Es el tipo de mejora que no consigues comprimiendo el fichero otra vez.

Bajar la prioridad también es una herramienta

La otra mitad del atributo se usa menos y a veces rinde más. fetchpriority="low" sirve para apartar de la cola recursos que están dentro del viewport pero no importan.

El caso canónico es un carrusel. Las tres o cuatro diapositivas de un carrusel están todas en el viewport según el layout, así que todas suben a prioridad alta y todas compiten con la primera, que es la única que el usuario va a ver. Marcar la primera con high y el resto con low reordena la cola de forma que la visible llega antes:

<div class="carrusel">
  <img src="d1.avif" width="1200" height="675" alt="Sala principal" fetchpriority="high">
  <img src="d2.avif" width="1200" height="675" alt="Terraza" fetchpriority="low" loading="lazy">
  <img src="d3.avif" width="1200" height="675" alt="Cocina" fetchpriority="low" loading="lazy">
</div>

El mismo razonamiento vale para logotipos de patrocinadores en la cabecera, iconos decorativos y avatares: están arriba, pero nadie mide su tiempo de carga.

Solo una imagen puede llevar high, y si pones dos no has priorizado nada

La prioridad es un recurso de suma cero. Si marcas cinco imágenes con fetchpriority="high", las cinco compiten entre ellas en la misma categoría y el resultado es indistinguible de no haber marcado ninguna, con el agravante de que ahora también compiten con la hoja de estilos y con la fuente crítica, que legítimamente estaban en alta. He visto páginas empeorar el LCP en 200 milisegundos por añadir el atributo a todo el listado de productos «por si acaso».

La regla es literal: como mucho un fetchpriority="high" por página, y solo si esa imagen es el elemento del LCP. Y para saber cuál es, no lo adivines: en el panel de rendimiento del navegador, el marcador de LCP te dice el nodo exacto, y en los datos de campo la librería oficial de métricas te lo entrega en el atributo del evento.

Hay un caso en el que ni siquiera hace falta el atributo, y conviene saberlo para no añadir ruido. Chromium tiene una heurística que sube automáticamente a alta la primera imagen suficientemente grande que encuentra en el viewport inicial, si llega pronto en el documento. Si tu hero es lo primero del body, es posible que ya esté priorizado y que el atributo no cambie nada medible. La forma de comprobarlo es la columna de prioridad del panel de red, que no está visible por defecto: se activa con el clic derecho sobre las cabeceras de la tabla. Si ya pone «High» sin el atributo, no lo pongas. Si pone «Low», acabas de encontrar entre 100 y 500 milisegundos.

Y un último detalle que rompe intuiciones: fetchpriority no cambia el orden en que se descubren los recursos, solo el orden en que se piden una vez descubiertos. Si tu imagen del hero se inyecta por JavaScript o vive en un background-image de una hoja de estilos, el atributo no existe y el problema es de descubrimiento, no de prioridad. Ahí la herramienta es <link rel="preload" as="image"> con su propio imagesrcset e imagesizes.

decoding: sacar el trabajo de píxeles del camino

Descargar una imagen y decodificarla son dos cosas distintas. La decodificación convierte los bytes comprimidos en un mapa de píxeles en memoria, y es una operación de CPU que escala con el número de píxeles del fichero, no con su peso comprimido. Una imagen de 2000 por 1000 son dos millones de píxeles, y decodificarla en un móvil de gama media cuesta del orden de 20 a 60 milisegundos. Un AVIF cuesta más que un JPEG del mismo tamaño, porque el formato es computacionalmente más caro de decodificar a cambio de comprimir mejor.

El atributo decoding tiene tres valores y controla si el navegador puede hacer ese trabajo fuera del hilo principal:

  • sync: decodifica en el hilo principal antes de presentar el siguiente fotograma. Bloquea, pero garantiza que la imagen aparece en el mismo pintado que el resto del contenido.
  • async: permite decodificar fuera del hilo principal y presentar el contenido sin esperar. La imagen puede aparecer un fotograma más tarde.
  • auto: por defecto. El navegador decide, y en la práctica hace algo muy parecido a async en la mayoría de los casos modernos.

La diferencia práctica entre auto y async es pequeña hoy, y en muchas mediciones no se distingue. Donde async sí paga es en inserciones de imágenes durante la interacción: cuando el usuario abre una galería, filtra un listado o navega en un cliente, insertar diez <img> con decoding="sync" puede meter cien milisegundos de decodificación en el hilo principal justo en el fotograma de respuesta, y eso sale directamente en el INP.

Para ese caso hay una herramienta mejor que el atributo, y es el método decode(), que devuelve una promesa que se resuelve cuando la imagen está lista para pintarse sin bloquear:

async function mostrarSinParpadeo(url, contenedor) {
  const img = new Image();
  img.src = url;
  img.decoding = 'async';
  try {
    await img.decode();          // decodifica fuera del hilo principal
  } catch {
    return;                       // fallo de red o formato no soportado
  }
  contenedor.replaceChildren(img); // insertar ya decodificada: cero trabajo en el pintado
}

Este patrón resuelve a la vez dos problemas: no bloquea el hilo al insertar, y evita el parpadeo de un elemento que aparece vacío y se rellena un fotograma después. Es la forma correcta de cambiar la imagen grande de un visor o de un carrusel.

⚔️ Reto práctico

Abre tu página de inicio con el panel de red y activa la columna de prioridad. Anota la prioridad inicial de la imagen del LCP. Si es baja, añade fetchpriority="high" y vuelve a medir el LCP cinco veces con red estrangulada, comparando la mediana antes y después. Después mide el tiempo de decodificación de esa misma imagen en el panel de rendimiento buscando el evento de decodificación de imagen, y comprueba si cambia al pasar de JPEG a AVIF con el mismo peso.