wandres.dev
IMÁGENES I · Formatos y compresión

Negociar el formato con la cabecera Accept

Servir AVIF, WebP o el original desde una sola URL: cómo se implementa en nginx y en el borde, por qué Vary fragmenta tu caché, y los cuatro fallos que rompen imágenes en producción.

⏱ 20 min

El elemento adaptativo resuelve la selección de formato en el cliente y a cambio te obliga a escribir el marcado en cada plantilla y a mantener un archivo por formato y por tamaño. La negociación por cabecera hace la misma elección en el servidor, con una sola URL en el HTML. Es más limpia, es más barata de almacenar, y tiene cuatro formas de romperse que hay que conocer antes de desplegarla.

🎯 Al terminar esta lección sabrás
  • Describir el intercambio completo de cabeceras de una negociación de formato.
  • Implementarla en nginx y en un trabajador de borde con código funcional.
  • Explicar por qué Vary: Accept fragmenta la caché y cómo normalizar la clave.
  • Enumerar los cuatro modos de fallo y la comprobación que detecta cada uno.

El intercambio

Cuando el navegador pide un recurso de imagen, envía una cabecera Accept que enumera los tipos que sabe decodificar, con preferencias. El servidor mira esa lista, elige la representación que mejor le encaja, y responde con dos cabeceras clave: Content-Type, que dice qué ha enviado, y Vary: Accept, que dice de qué ha dependido la elección.

GET /img/foto.jpg HTTP/2
Accept: image/avif,image/webp,image/apng,image/svg+xml,*/*;q=0.8

HTTP/2 200
content-type: image/avif
content-length: 42118
vary: Accept
cache-control: public, max-age=31536000, immutable

Fíjate en que la URL pedida termina en .jpg y lo que llega es AVIF. Eso es correcto y es todo el mecanismo: la URL identifica el recurso, no su codificación.

Los valores concretos de Accept varían entre navegadores y entre versiones del mismo navegador. Estos son los que se ven en un registro real de 2026:

Motor Forma del valor
Chromium image/avif,image/webp,image/apng,image/svg+xml,*/*;q=0.8
Firefox image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8

La regla de implementación que se deriva de esa variabilidad es una sola y no admite excepción: detecta por subcadena, nunca por comparación exacta ni por análisis estricto de los factores de calidad. Si tu lógica exige que el valor sea uno de una lista cerrada, un cambio de versión de navegador te dejará sirviendo el formato de reserva a todo el mundo sin que nada falle visiblemente.

Dos implementaciones

nginx

El patrón canónico usa un mapa que traduce la cabecera a un sufijo y una cadena de reserva de archivos:

# En el contexto http.
map $http_accept $sufijo {
    default        "";
    "~*image/avif" ".avif";
    "~*image/webp" ".webp";
}

# nginx antiguo puede no conocer el tipo. Declararlo no molesta.
types {
    image/avif  avif;
    image/webp  webp;
}

server {
    location /img/ {
        root /var/www;

        # Prueba foto.jpg.avif, luego foto.jpg.webp, luego foto.jpg.
        try_files $uri$sufijo $uri =404;

        add_header Vary Accept always;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        add_header Timing-Allow-Origin "*" always;
    }
}

Tres detalles que deciden si funciona.

El orden de las entradas del mapa importa. nginx evalúa las expresiones regulares en el orden en que están escritas y se queda con la primera que coincide, así que AVIF va antes que WebP. Al revés, un navegador que admita los dos recibiría WebP siempre.

La convención de nombres es de sufijo acumulado. El archivo AVIF se llama foto.jpg.avif, no foto.avif. Es lo que permite que la reserva sea una sola línea, y es lo que hay que respetar en la canalización que genera las variantes.

El tipo de contenido lo deduce nginx de la extensión final, que en este esquema es la correcta. Si tu instalación no conoce los tipos modernos, el bloque de tipos los añade.

En el borde

Con un trabajador delante de tu almacén de objetos, la lógica es la misma y además puedes normalizar la clave de caché en el mismo sitio:

export default {
  async fetch(peticion) {
    const acepta = peticion.headers.get('Accept') || '';
    const formato = acepta.includes('image/avif') ? 'avif'
      : acepta.includes('image/webp') ? 'webp'
      : 'jpg';

    const url = new URL(peticion.url);
    url.pathname = url.pathname.replace(/\.[a-z0-9]+$/i, '.' + formato);

    const respuesta = await fetch(url.toString(), {
      cf: { cacheEverything: true, cacheTtl: 31536000 },
    });

    const salida = new Response(respuesta.body, respuesta);
    salida.headers.set('Vary', 'Accept');
    salida.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
    return salida;
  },
};

La ventaja de resolverlo aquí es que el origen ve una URL por formato, ya explícita, así que su caché no depende de ninguna cabecera y su tasa de acierto es máxima. La negociación vive en un solo salto, el más cercano al usuario.

Por qué Vary fragmenta la caché

Vary: Accept le dice a cualquier caché intermedia que no puede reutilizar esta respuesta para una petición con un valor distinto de Accept. Literalmente distinto: byte a byte.

Y los valores de Accept son muchos. Cada versión de cada navegador puede tener el suyo, el orden de los tipos varía, algunos añaden entradas. Una caché que respete la cabecera al pie de la letra acabará guardando decenas de copias del mismo AVIF, una por cada cadena distinta que haya visto, con tres consecuencias: la tasa de acierto se hunde, el almacenamiento se multiplica, y los usuarios con la variante menos común pagan un fallo de caché y un viaje al origen.

La solución es normalizar la clave de caché antes de que la fragmentación ocurra, reduciendo el valor a un enumerado de tres estados. Si tu CDN admite transformaciones de petición, la normalización se hace ahí:

// Normalizacion previa: de decenas de cadenas a tres valores.
const acepta = peticion.headers.get('Accept') || '';
const clave = acepta.includes('image/avif') ? 'avif'
  : acepta.includes('image/webp') ? 'webp'
  : 'base';

// La peticion que llega al origen lleva un Accept canonico.
const alOrigen = new Request(peticion, {
  headers: new Headers({ ...Object.fromEntries(peticion.headers), Accept: clave }),
});

Si tu CDN no permite tocar la clave, la alternativa honesta es no negociar: usa el elemento adaptativo y sirve URL distintas por formato, donde el problema no existe porque cada representación tiene su propia dirección.

🛑
Nunca pongas Vary: Accept en respuestas de HTML

La cabecera solo debe acompañar a las respuestas que de verdad varían con ella, que son las imágenes negociadas. En un documento HTML es puro daño: los navegadores mandan valores de Accept distintos para navegaciones que para subrecursos, y para prerenderizados que para navegaciones normales, así que fragmentarás la caché del recurso más valioso de tu sitio sin ninguna contrapartida. Configura la cabecera en el bloque de las imágenes, no globalmente.

Los cuatro modos de fallo

Uno: la CDN no reenvía Accept al origen. El origen ve una petición sin la cabecera, elige la reserva, y sirves JPEG a todo el mundo. No hay error, no hay imagen rota: simplemente la optimización no existe y nadie se entera. Hay proveedores que documentan explícitamente que no reenvían esta cabecera. Comprobación:

curl -s -o /dev/null -D - https://ejemplo.com/img/foto.jpg \
  -H 'Accept: image/avif,image/webp,*/*' | grep -i -E 'content-type|vary'

Si el tipo de contenido devuelve JPEG con ese Accept, o falta la cabecera de variación, la negociación no está llegando.

Dos: la caché ignora Vary. Es el fallo grave, porque produce imágenes rotas. Un usuario con AVIF calienta la caché con una respuesta AVIF, y el siguiente usuario sin soporte recibe exactamente esos bytes con un tipo de contenido que su navegador no sabe decodificar. Comprobación: pedir dos veces con cabeceras distintas y verificar que el tipo cambia.

for a in 'image/avif,*/*' 'image/webp,*/*' '*/*'; do
  printf '%-22s -> ' "$a"
  curl -s -o /dev/null -w '%{content_type}\n' \
    -H "Accept: $a" https://ejemplo.com/img/foto.jpg
done

La salida correcta son tres tipos distintos. Dos iguales significan que algo está cacheando de más.

Tres: el navegador admite el formato y no lo anuncia. No es hipotético: hay un fallo documentado en el sistema de seguimiento de Firefox precisamente por eso, la cabecera Accept no incluía AVIF aunque el soporte estuviera activado. Cuando ocurre, tu negociación sirve la reserva a usuarios que podrían haber recibido el formato eficiente. No hay arreglo desde tu lado, y sí hay una consecuencia: la negociación por cabecera es siempre igual o más conservadora que el elemento adaptativo, porque este último pregunta al decodificador y aquella se fía de lo que el navegador declara.

Cuatro: los clientes que no son navegadores. El rastreador de una red social pidiendo tu imagen de vista previa, un servicio de miniaturas, un cliente de correo, un lector de fuentes. Muchos envían un Accept genérico o ninguno, y tu lógica tiene que devolverles la reserva universal sin dudar. Es exactamente lo que hace el valor por defecto del mapa de nginx, y es el motivo de que el orden de comprobación termine siempre en JPEG.

Cuándo elegir cada mecanismo

Elemento adaptativo Negociación por cabecera
Marcado Cuatro o más nodos por imagen Un elemento de imagen
URL por imagen Una por formato Una sola
Objetos almacenados Tamaños por formatos Tamaños por formatos, pero sin exponerlos
Funciona en fondos de CSS No
Funciona en metadatos de vista previa No
Requiere control del servidor No
Riesgo de imagen rota Ninguno Real, si la caché ignora la variación
Precarga con filtro de tipo No aplica

Las dos filas que suelen decidir son la de los fondos de CSS y la de los metadatos. Una imagen referenciada desde una hoja de estilos o desde una etiqueta de vista previa no puede usar el elemento adaptativo, así que si quieres formatos modernos ahí, la negociación por cabecera es el único camino. Y la última fila recuerda una pérdida real: con una sola URL ya no puedes usar el atributo de tipo en una precarga para filtrar por soporte, porque el tipo lo decide el servidor después.

El enfoque que mejor funciona en sitios grandes no es elegir uno: es negociar por cabecera para todo el catálogo y reservar el elemento adaptativo para los pocos casos que necesitan dirección de arte, donde el marcado extra está justificado por un cambio de encuadre y no por un cambio de códec.

La negociación por cabecera convierte un fallo de configuración de caché en imágenes rotas para una minoría, y esa minoría no te lo va a contar

Con el elemento adaptativo, un error de formato es imposible: cada URL sirve exactamente un códec y el navegador solo pide el que entiende. Con negociación, la corrección depende de que todas las cachés del camino respeten la variación: la CDN, un proxy corporativo, un antivirus que intercepta el tráfico, la caché compartida de un operador móvil. Basta con que una de ellas la ignore para que un usuario reciba bytes AVIF con un decodificador que no los entiende, y lo que ve es un hueco vacío donde había una foto. Lo insidioso es el perfil de quien lo sufre: son los usuarios con navegadores viejos, en redes intermediadas, en dispositivos de gama baja, es decir, exactamente los que no van a escribirte un informe de error y los que menos aparecen en tu monitorización, porque tu monitorización también corre en sus navegadores y probablemente falle. El resultado es un fallo que puede durar meses sin aparecer en ningún panel. La defensa es barata y hay que montarla el mismo día que se despliega la negociación: una comprobación periódica que pida la misma URL con los tres valores de Accept y verifique que devuelve tres tipos de contenido distintos, y un contador en tu instrumentación de imágenes que no cargan, enviado desde el evento de error del elemento de imagen. Con esas dos cosas, un fallo de variación se detecta en minutos. Sin ellas, se detecta cuando alguien se queja, y esos usuarios no se quejan: se van.