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

srcset: los descriptores w y x, y por qué no son intercambiables

Cómo se declara un conjunto de candidatos de imagen, qué significa exactamente cada descriptor, y cómo generar la escalera de anchos sin desperdiciar bytes ni almacenamiento.

⏱ 18 min

Servir una sola imagen a todo el mundo significa servir la peor imagen posible: o pesa de más para el móvil o se ve borrosa en el portátil de 2x. srcset existe para que tú declares un menú de candidatos y el navegador elija, porque el navegador sabe cosas que tú no sabes en tiempo de compilación: la densidad de píxeles real de la pantalla, el ancho de la ventana en ese instante y, en algunos casos, la preferencia de ahorro de datos del usuario. La parte difícil no es la sintaxis, son las dos formas incompatibles de expresar el menú.

🎯 Al terminar esta lección sabrás
  • Distinguir cuándo un descriptor x es suficiente y cuándo hace falta uno w.
  • Escribir un srcset con descriptores w que cubra el rango real de dispositivos sin huecos ni excesos.
  • Calcular cuántos bytes se ahorran al pasar de una imagen fija a un conjunto de candidatos.
  • Elegir el número de pasos de la escalera de anchos con un criterio, no a ojo.

Por qué el navegador tiene que decidir, y no tú

El servidor no sabe nada del cliente que va a pintar la imagen. La cabecera User-Agent no dice la densidad de la pantalla, y el ancho de la ventana llega demasiado tarde: cuando el JavaScript puede leer window.innerWidth, el escáner de precarga ya ha pasado por el HTML y ha decidido qué descargar. Ese escáner es la razón de que una solución en JavaScript sea estructuralmente peor: corre en un hilo aparte, lee el HTML en bruto antes de que exista el DOM, y arranca las descargas de imágenes y hojas de estilo cientos de milisegundos antes de que el parser principal llegue ahí. Una imagen que solo existe como data-src es invisible para el escáner y pierde esa ventaja.

srcset resuelve el problema moviendo la decisión al único sitio que tiene toda la información: el propio navegador, en el momento del escaneo. Tú declaras los candidatos, él aplica el algoritmo. Y aplica el algoritmo con datos que ni siquiera están expuestos a JavaScript de forma fiable, como el factor de zoom del sistema operativo o el ajuste de escala de la interfaz.

El atributo tiene dos gramáticas distintas, y mezclarlas en el mismo srcset es un error de sintaxis: el navegador descarta el atributo entero y cae a src. Un fallo silencioso, sin aviso en consola en la mayoría de los casos, que devuelve la página al comportamiento de 2010.

El descriptor x: densidad, y solo densidad

El descriptor x declara para qué densidad de píxeles está pensado cada candidato.

<img
  src="logo-1x.png"
  srcset="logo-1x.png 1x, logo-2x.png 2x, logo-3x.png 3x"
  width="180"
  height="48"
  alt="Logotipo de la empresa"
>

Aquí el navegador hace una cuenta trivial: mira su devicePixelRatio y coge el candidato cuyo descriptor sea igual o superior. Con un DPR de 2 coge logo-2x.png. Con un DPR de 3 coge logo-3x.png. Con un DPR de 1,5 —muy común en portátiles Windows con escalado del 150 por ciento— coge logo-2x.png, porque el algoritmo redondea hacia arriba para no degradar la nitidez.

El descriptor x es correcto solo cuando la imagen tiene un tamaño de maquetación fijo, independiente del ancho de la ventana. Un logotipo en la cabecera, un icono, un avatar de 40 por 40 píxeles CSS, la firma de un artículo. Si el tamaño de la imagen depende del viewport, el descriptor x es inservible, porque un candidato de 2x para una ventana de 400 píxeles es un candidato de 0,5x para una ventana de 1600.

Cuando omites el descriptor, el valor por defecto es 1x. Por eso srcset="a.png, b.png 2x" es válido: a.png se interpreta como a.png 1x. Y por eso srcset="a.png, b.png" es un error: dos candidatos declarando la misma densidad.

El descriptor w: el ancho intrínseco del fichero

El descriptor w no describe para qué pantalla sirve el candidato. Describe cuántos píxeles de ancho tiene el fichero, y punto. Es un dato objetivo del recurso, no una intención.

<img
  src="hero-800.jpg"
  srcset="
    hero-400.jpg   400w,
    hero-800.jpg   800w,
    hero-1200.jpg 1200w,
    hero-1600.jpg 1600w,
    hero-2400.jpg 2400w
  "
  sizes="(min-width: 1100px) 1000px, 100vw"
  width="1600"
  height="900"
  alt="Vista aérea del puerto al amanecer"
>

El cambio de mentalidad importa: con w no le estás diciendo al navegador cuándo usar cada fichero, le estás diciendo qué es cada fichero. El cuándo lo calcula él combinando esos anchos con sizes y con la densidad de la pantalla. Esa separación es deliberada: los mismos ficheros sirven para cualquier maquetación, y cambiar el CSS del componente no obliga a regenerar imágenes, solo a corregir sizes.

La consecuencia práctica es que un srcset con w no funciona sin sizes. Si lo omites, el navegador asume sizes="100vw", es decir, que la imagen ocupa todo el ancho de la ventana. En una galería de tres columnas eso significa descargar sistemáticamente el triple de píxeles de los necesarios. Es el error más caro de esta lección y tiene su propia lección: cómo el navegador elige un candidato.

La escalera se diseña por bytes, no por anchos redondos

Casi todo el mundo genera la escalera con números bonitos: 400, 800, 1200, 1600, 2400. El problema es que el peso de un JPEG o un AVIF crece aproximadamente con el cuadrado del ancho, así que los escalones de arriba están mucho más separados en bytes que los de abajo. Entre 400w y 800w puede haber 45 KB de diferencia; entre 1600w y 2400w, 300 KB. Estás dando resolución fina donde no importa y saltos brutales donde sí.

El criterio correcto es espaciar los escalones por tamaño de fichero, no por ancho en píxeles, buscando un salto constante de en torno al 20 o 25 por ciento en bytes entre candidatos consecutivos. En la práctica sale una escalera con más pasos arriba que abajo, algo como 400, 640, 900, 1200, 1500, 1800, 2200, 2600. Genera los ficheros, mira los pesos reales y ajusta.

El segundo detalle que casi nadie tiene en cuenta: cada candidato extra multiplica el coste de almacenamiento y de invalidación de la CDN, pero no el de descarga del usuario, que sigue bajando exactamente un fichero. Por eso ocho candidatos no son un exceso si tu pipeline de imágenes los genera solo. Lo que sí es un exceso es un candidato de 3200w cuando tu contenedor nunca pasa de 1000 píxeles CSS: ese fichero no lo va a pedir nadie salvo un móvil con DPR 3 y ventana de 1100, un caso que no existe. Recorta la escalera por arriba con el ancho máximo real de tu maquetación multiplicado por 2, no por 3.

Cuánto se ahorra de verdad

Merece la pena hacer la cuenta una vez para tener el orden de magnitud en la cabeza. Toma una imagen de cabecera a ancho completo, entregada en AVIF con calidad perceptual equivalente:

Candidato Peso AVIF aprox. Quién lo pide
400w 18 KB Móvil pequeño, DPR 1
800w 52 KB Móvil de 390 CSS con DPR 2
1200w 98 KB Tablet, portátil DPR 1
1600w 155 KB Portátil DPR 1 con ventana ancha
2400w 310 KB Portátil 2x, escritorio grande

Servir el fichero de 2400w a todo el mundo, que es lo que hace una página sin srcset, cuesta 310 KB al móvil que solo necesitaba 52 KB. Son 258 KB de más. A la velocidad efectiva de una conexión móvil mediocre —pongamos 1,6 Mbps, que es lo que simula el estrangulamiento estándar de las herramientas de auditoría— eso es 1,3 segundos añadidos a un recurso que además suele ser el elemento del LCP. Y el usuario paga esos bytes de su tarifa de datos.

La cuenta inversa también es instructiva. Si tu imagen de cabecera ya se sirve con srcset correcto, el margen que queda por optimizar en esa imagen es de decenas de kilobytes, no de cientos. En ese punto conviene dejar de tocar la imagen y mirar el JavaScript.

⚔️ Reto práctico

Coge la imagen más grande de tu página de inicio. Genera cinco candidatos con tu herramienta de imágenes y anota el peso real de cada uno. Calcula el salto porcentual entre escalones consecutivos: si alguno supera el 40 por ciento, mete un escalón intermedio; si alguno baja del 12 por ciento, quítalo. Después abre el panel de red del navegador con el móvil emulado y comprueba qué candidato pide de verdad. Si pide el más grande, el problema está en sizes y no en srcset.