sizes y el algoritmo de selección: por qué casi todo el mundo lo escribe mal
El algoritmo exacto con el que el navegador elige un candidato de srcset, por qué sizes es una promesa sobre el layout y no una media query, y cómo escribirlo sin servir el triple de píxeles.
Si hay una sola causa dominante de imágenes servidas al doble o al triple de la resolución necesaria en la web real, es un sizes mal escrito. El atributo parece una media query, se escribe con la sintaxis de una media query, y no es una media query: es una promesa que le haces al navegador sobre el ancho que va a ocupar la imagen en la maquetación, hecha antes de que exista la maquetación. Entender el algoritmo de selección completo es la diferencia entre escribirlo bien de una vez y estar copiando sizes="100vw" de un ejemplo durante años.
- Reproducir a mano el algoritmo de selección de candidato que ejecuta el navegador.
- Escribir un
sizescorrecto para una maquetación de rejilla con varios puntos de ruptura. - Detectar un
sizesmentiroso midiendo el candidato realmente descargado. - Decidir cuándo usar
sizes="auto"y qué requisitos impone.
sizes no describe la ventana, describe la imagen
El malentendido de raíz es este: la gente lee sizes="(min-width: 800px) 50vw, 100vw" como «si la ventana mide más de 800 píxeles, usa la imagen del 50 por ciento». No dice eso. Dice: «si la ventana mide más de 800 píxeles, esta imagen va a ocupar 50vw de ancho en la página; en caso contrario ocupará 100vw». Es una declaración sobre el resultado del layout, no una instrucción de selección.
La distinción no es filosófica. Explica por qué sizes puede estar sintácticamente perfecto y ser completamente falso, y por qué el navegador no te avisa: él no puede saber que mientes, porque cuando lee el atributo todavía no ha hecho layout. Confía en tu palabra y descarga en consecuencia. Cuando el layout ocurre, doscientos milisegundos más tarde, la descarga ya va por la mitad.
Por eso las condiciones de sizes son media conditions sin @media, evaluables sin CSS resuelto: (min-width: 800px), (orientation: landscape), (min-resolution: 2dppx). Y por eso no puedes usar unidades relativas al contenedor: 50% no es un valor válido en sizes, porque el porcentaje necesitaría un contenedor ya maquetado. Las unidades admitidas son las absolutas y las relativas al viewport: px, em, rem, vw, vh, y calc() con ellas.
Un detalle que cuesta caro: em en sizes se resuelve contra el tamaño de fuente raíz, no contra el del elemento, precisamente porque no hay elemento maquetado todavía.
El algoritmo, paso a paso
Este es el procedimiento completo. Ejecútalo a mano una vez y no volverás a escribir sizes a ciegas.
Paso 1. Resolver sizes para obtener el ancho de origen. El navegador recorre la lista de izquierda a derecha y se queda con la primera condición que se cumple. El último valor no lleva condición y actúa como valor por defecto. El resultado es un ancho en píxeles CSS, que la especificación llama source size.
Paso 2. Calcular la densidad efectiva de cada candidato. Para cada entrada con descriptor w, divide el ancho del fichero entre el ancho de origen del paso 1. Un fichero de 1200w con un ancho de origen de 400 píxeles CSS tiene una densidad efectiva de 3. Con esta división, los descriptores w se convierten internamente en descriptores x, y a partir de aquí los dos tipos de srcset comparten el mismo camino.
Paso 3. Elegir el candidato. El navegador busca el candidato con la densidad efectiva más baja que sea igual o mayor que el DPR de la pantalla. Si ninguno llega, coge el más grande disponible.
Un ejemplo completo. Móvil con DPR 3 y ventana de 390 píxeles CSS, en una galería de dos columnas con 16 píxeles de separación y 16 de margen a cada lado:
<img
srcset="foto-320.avif 320w, foto-480.avif 480w, foto-640.avif 640w,
foto-960.avif 960w, foto-1280.avif 1280w"
sizes="(min-width: 900px) 280px, calc((100vw - 48px) / 2)"
src="foto-640.avif"
width="640" height="640" alt="Cerámica esmaltada en azul cobalto">
La ventana mide 390, así que la primera condición no se cumple y el ancho de origen es calc((390 - 48) / 2), es decir, 171 píxeles CSS. Densidades efectivas: 320/171 = 1,87; 480/171 = 2,81; 640/171 = 3,74. El DPR es 3, así que el primer candidato que llega o supera 3 es foto-640.avif. Descarga 640 píxeles de ancho para pintar 171 puntos CSS. Correcto.
Ahora repite el ejercicio con sizes="100vw", que es lo que habría por defecto si lo omitieras: ancho de origen 390, densidades 0,82 / 1,23 / 1,64 / 2,46 / 3,28. El elegido sería foto-1280.avif. El doble de ancho, cuatro veces el área, unas tres veces y media el peso. Ese es exactamente el fallo del que habla el título de la lección, y ocurre en galerías, en tarjetas de producto y en listados de artículos por toda la web.
flowchart TB A[Escaner de precarga lee el img] --> B[Resolver sizes a un ancho en px CSS] B --> C[Dividir cada w entre ese ancho] C --> D[Comparar con el DPR de la pantalla] D --> E[Elegir la densidad mas baja que llegue al DPR] E --> F[Descargar ese candidato] B --> G[Si sizes miente todo lo demas hereda la mentira] style B fill:#f9e2af,color:#11111b style C fill:#89b4fa,color:#11111b style E fill:#a6e3a1,color:#11111b style G fill:#f38ba8,color:#11111b
Escribir un sizes que no mienta
La regla operativa es simple de enunciar y exige un poco de disciplina: sizes tiene que ser una traducción literal del CSS que gobierna el ancho de esa imagen. Si el CSS cambia, sizes cambia. Si no puedes escribir la fórmula, es que no sabes cuánto mide tu imagen, y ese es un problema anterior.
Para una rejilla con puntos de ruptura, el procedimiento es mecánico. Escribe el ancho del contenedor en cada tramo, réstale los rellenos y las separaciones, divide entre el número de columnas de ese tramo, y ordena las condiciones de la más restrictiva a la menos restrictiva, porque gana la primera que se cumple.
/* El CSS real de la galería */
.galeria { display: grid; gap: 16px; padding-inline: 16px; }
@media (min-width: 600px) { .galeria { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 900px) { .galeria { grid-template-columns: repeat(3, 1fr); max-width: 1200px; margin-inline: auto; } }
<!-- La traduccion literal a sizes -->
<img
sizes="(min-width: 900px) 389px,
(min-width: 600px) calc((100vw - 48px) / 2),
calc(100vw - 32px)"
srcset="p-400.avif 400w, p-600.avif 600w, p-800.avif 800w, p-1200.avif 1200w"
src="p-600.avif" width="800" height="600" alt="Silla de roble macizo">
El 389px del primer tramo sale de la cuenta: contenedor limitado a 1200, menos 32 de relleno lateral, menos 32 de las dos separaciones, dividido entre 3. Ese número fijo es correcto porque a partir de 900 píxeles el contenedor deja de crecer. Ponerlo como 33vw sería mentir en cualquier ventana mayor de 1264.
Hay una salida de emergencia para el caso en que la maquetación sea imposible de expresar: sizes="auto", que le pide al navegador que use el ancho real del elemento en lugar de tu fórmula. Tiene una condición estricta y es la clave para entenderlo: solo funciona sobre una imagen con loading="lazy", porque solo en ese caso el navegador ha hecho ya layout cuando decide qué descargar. Sobre una imagen ansiosa no hay layout todavía y el atributo se ignora. Y si el elemento no tiene un ancho determinado por CSS en el momento de la evaluación, el ancho de origen resuelve a cero y el navegador coge el candidato más pequeño, que se verá borroso. Es una herramienta útil para rejillas complejas fuera de la primera pantalla, no un sustituto general.
La especificación permite explícitamente que el agente de usuario elija cualquier candidato, no necesariamente el que dicta el algoritmo, si tiene motivos. Y los tiene, en dos casos que verás en producción.
El primero es la caché. Si el navegador ya tiene en caché un candidato de la misma lista con densidad efectiva mayor o igual a la necesaria, puede servirlo sin descargar nada. Esto es correcto y deseable, pero envenena tus mediciones: si vas ampliando la ventana y recargando, verás que el navegador se queda pegado a la imagen grande que ya descargó. Para medir la selección hay que medir siempre con caché desactivada y sesión limpia, o los datos no significan nada.
El segundo es el ahorro de datos. Con la reducción de datos activa, algunos navegadores basados en Chromium bajan un escalón deliberadamente. No es un fallo, es la característica funcionando.
Y hay un tercer factor que rompe la intuición de mucha gente: el navegador nunca elige un candidato con densidad efectiva menor que el DPR salvo que no quede otra. Es decir, el algoritmo está sesgado hacia la calidad, no hacia el ahorro. En una pantalla con DPR 3, un sizes inflado un 20 por ciento no te cuesta un 20 por ciento más de bytes: te cuesta un escalón entero de la escalera, que puede ser el 60 por ciento más de peso. Los errores de sizes no se pagan de forma proporcional, se pagan a saltos. Por eso conviene, cuando dudes, redondear la fórmula ligeramente hacia abajo y no hacia arriba: perder un uno por ciento de nitidez teórica en un caso límite es infinitamente más barato que subir un escalón en todos los casos.
Verificar en lugar de suponer
Un sizes no se da por bueno porque compile. Se comprueba con dos medidas.
La primera, en el propio navegador. En la consola, sobre una imagen ya cargada, currentSrc te dice qué candidato eligió de verdad, y las propiedades naturales te dan el ancho del fichero descargado:
const img = document.querySelector('.galeria img');
console.log({
elegido: img.currentSrc,
anchoFichero: img.naturalWidth,
anchoPintado: img.getBoundingClientRect().width,
dpr: window.devicePixelRatio,
factor: img.naturalWidth / (img.getBoundingClientRect().width * window.devicePixelRatio),
});
Ese factor es el número que importa. Debe salir entre 1 y 1,4. Por encima de 1,5 estás descargando de más; muy por debajo de 1, se verá borroso. Recorre tu página con este fragmento en varias anchuras de ventana y con la emulación de DPR 2 y 3 activada, y tendrás un diagnóstico completo en dos minutos.
La segunda medida es agregada. Las auditorías automáticas de rendimiento traen una comprobación de imágenes sobredimensionadas que hace exactamente esta cuenta sobre todas las imágenes de la página y reporta los kilobytes desperdiciados. Sirve como red de seguridad en integración continua, pero es menos fiable que la medida manual porque solo evalúa una anchura de ventana concreta.
Escribe el sizes correcto para esta maquetación: contenedor de ancho completo hasta 700 píxeles con 20 de relleno lateral; entre 700 y 1100, dos columnas con 24 de separación; a partir de 1100, tres columnas dentro de un contenedor limitado a 1080 con 24 de separación y sin relleno. Después comprueba con el fragmento de currentSrc que el factor cae entre 1 y 1,4 en anchuras de 380, 800 y 1400 píxeles.