Skip to content

HTTP cache partitioning: la misma imagen, descargada dos veces

Published:

Me llega un caso que parece trivial y no lo es. Una página en siteb.com embebe un iframe de sitea.com. Los dos, el iframe y la página padre, piden exactamente la misma imagen: https://imageurl.com/myImage.jpg. Primero la pide el iframe, después la pide el padre. La pregunta es sencilla: ¿la segunda petición se sirve desde caché?

La respuesta correcta es “depende del navegador”, y esa es justo la parte interesante. En Chrome se descarga dos veces. En Firefox y Safari, una sola vez. Mismo HTML, mismas cabeceras, mismo servidor, resultado distinto.

No es un bug de nadie. Es una decisión de diseño distinta sobre cómo se indexa la caché, y merece la pena entenderla porque afecta a cosas que damos por hechas todos los días.

Por qué la caché dejó de ser compartida

Durante años la caché HTTP se indexaba por un solo identificador: la URL del recurso. Si cdn.example/jquery.js ya estaba en disco, daba igual qué sitio lo pidiera; se servía de disco. Sobre eso se sostenía el argumento de los CDN públicos: cuantos más sitios sirvieran jQuery desde la misma URL, más probable era que quien navegara ya lo tuviera descargado.

El problema es que una caché global es un canal lateral perfecto. Si puedo medir cuánto tarda en resolverse un recurso, puedo deducir si ya estaba en el disco de quien navega, y de ahí deducir dónde ha estado. Los ataques concretos que documentó el equipo de Chrome son tres:

La respuesta de los navegadores fue particionar: la caché deja de ser una tabla global y pasa a ser una colección de compartimentos aislados. Safari lo hizo primero, sobre 2013. Chrome lo desplegó a partir de la versión 86, a finales de 2020. Firefox lo activó por defecto en la 85, en enero de 2021.

Hasta aquí, los tres coinciden. Donde se diferencian es en cuántos componentes lleva la clave.

La cache key, desglosada

Aquí está toda la diferencia. Chrome usa una clave triple:

Chrome, desde la v86
{ top-level site, frame site, URL del recurso }

Firefox y Safari usan una clave doble:

Firefox y Safari
{ top-level site, URL del recurso }

En ambos casos, “site” significa scheme://eTLD+1. El subdominio y el puerto no entran en el cálculo, así que https://a.example y https://www.a.example caen en la misma partición.

Apliquémoslo al caso. La petición del iframe:

Chrome           (https://siteb.com, https://sitea.com, https://imageurl.com/myImage.jpg)
Firefox/Safari   (https://siteb.com, https://imageurl.com/myImage.jpg)

Y la del padre, la misma URL desde el documento top-level:

Chrome           (https://siteb.com, https://siteb.com, https://imageurl.com/myImage.jpg)
Firefox/Safari   (https://siteb.com, https://imageurl.com/myImage.jpg)

Analicemos en detalle. En Firefox y Safari las dos claves son idénticas carácter a carácter, así que la segunda petición es un hit y el byte se sirve de disco. En Chrome el segundo componente cambia, sitea.com frente a siteb.com, así que son dos particiones distintas y la imagen se descarga dos veces dentro de la misma carga de página.

En la demo podemos pedir la imagen desde cada contexto y ver la clave que sale, qué particiones se van creando y cuántos bytes acaban viajando por la red. Cambiemos de navegador para comparar. Es una simulación: no se hace ninguna petición real ni se toca la caché del navegador, el resultado se calcula aplicando el modelo de cache key documentado de cada motor.

Por qué Chrome aísla más

La clave doble asume que quien publica la página top-level se fía de los frames que embebe. Es una suposición razonable en muchos casos y falsa en unos cuantos: un iframe de un tercero es código de otra persona ejecutándose dentro de nuestra página.

Con clave doble, ese iframe de un tercero comparte partición de caché con nuestro documento y con todos los demás frames de la página. Eso reabre una versión reducida del ataque original: en lugar de espiar entre sitios, se espía entre frames del mismo sitio. Un anuncio embebido puede sondear qué ha cacheado el documento que lo contiene.

Chrome metió el frame site en la clave precisamente para cerrar eso. En sus propios experimentos la diferencia de rendimiento entre clave doble y triple resultó pequeña, así que se quedaron con el aislamiento extra. Firefox y Safari optaron por el modelo simple: menos aislamiento entre frame y top-level, mejor tasa de aciertos en este caso concreto.

Ninguna de las dos posturas es gratis. Chrome paga cache misses; Firefox y Safari pagan superficie de ataque entre frames. Vale la pena tener claro que es un intercambio consciente y no un descuido.

Cadenas de iframes anidados

El caso se complica cuando hay más de un nivel: a.example embebe b.example, que a su vez embebe c.example, y es c.example quien pide el recurso.

La intuición dice que la clave debería llevar toda la cadena. No es así. Chrome usa el top-frame y el frame inmediato que hace la petición, y se salta lo que hay en medio:

Chrome: a.example > b.example > c.example, pide c.example
(https://a.example, https://c.example, https://x.example/lib.js)
                    ↑ b.example no aparece por ningún lado

Esto tiene una consecuencia práctica poco obvia: dos cadenas de anidamiento completamente distintas comparten partición mientras coincidan los extremos. Y al revés, mover un recurso un nivel arriba o abajo en la jerarquía de frames cambia la partición e invalida la caché.

Probemos a pedir lib.js desde cada nivel de la cadena y veamos qué entra y qué se ignora:

Qué se rompe en el día a día

Esto no es teoría de navegadores. Son cuatro suposiciones que arrastramos en proyectos reales:

El segundo caso es el que más veces sigo encontrando, y el que menos cuesta solucionar:

❌ Bad: apoyarse en una caché compartida que ya no existe
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
✅ Good: servir desde nuestro propio origen
<script src="/vendor/jquery-3.7.1.min.js"></script>

Chrome midió el coste agregado al desplegar el particionado. La tasa global de fallos de caché subió del 54% al 56%, un 3,6% más, y la fracción de bytes que vienen de red pasó del 75% al 78%. El impacto en First Contentful Paint quedó por debajo del 1% en la mayoría de cuantiles, con una regresión típica de en torno al 0,3%. Poco en la media, y bastante más en los casos concretos que dependían de compartir.

Compartir partición no basta: la eviction

Aquí conviene frenar, porque es el error que más veces he visto al depurar esto. Que la clave coincida no garantiza el hit. Compartir partición es condición necesaria, no suficiente.

En el caso de Firefox y Safari, las dos peticiones caen en la misma partición. Perfecto. Pero si la entrada ya no está en disco cuando llega la segunda petición, se vuelve a descargar igual. Y la entrada puede haber desaparecido por razones que no tienen absolutamente nada que ver con el particionado:

Particionado y eviction son dos causas de MISS completamente independientes, y en el waterfall se ven exactamente igual. Distinguirlas importa porque llevan a soluciones distintas: una se afronta con cabeceras y con presupuesto de bytes, y sobre la otra no podemos hacer nada desde nuestro código.

Podemos verlo en la demo de arriba con el control Entre peticiones: elegimos Firefox o Safari, pedimos la imagen desde el iframe, descartamos la entrada y volvemos a pedirla desde el padre. La clave es idéntica, la partición es la misma, y aun así hay MISS.

El particionado no solo divide, también aprieta

Y aquí las dos causas se cruzan, que es la parte poco obvia.

Cada partición se comporta como una caché separada con su propio espacio dentro del mismo disco. Un recurso que antes ocupaba una entrada ahora ocupa N, una por cada combinación de sitios desde la que se pide. La misma fuente de Google Fonts puede tener una copia por cada sitio que visitamos, y en Chrome incluso varias copias dentro de un mismo sitio si la piden frames distintos.

El presupuesto de disco, en cambio, no se multiplicó. Así que más entradas compiten por el mismo espacio, la caché se llena antes y el LRU empieza a descartar entradas mucho antes. El particionado sube la tasa de fallos por dos vías a la vez: claves que ya no coinciden, y entradas que sobreviven menos tiempo porque hay más presión.

Merece la pena ser preciso con los números: los datos que publicó Chrome, ese 3,6% más de fallos y ese 4% más de bytes desde red, son la cifra agregada del despliegue. Recogen el efecto combinado de ambos mecanismos y no vienen desglosados por causa. Cuando leemos “el particionado cuesta un 3-4%”, lo que estamos leyendo incluye la duplicación de copias, no solo el cambio de clave.

Una nota para reproducirlo en DevTools

Si queremos reproducir el caso del iframe y el padre de forma fiable, dos avisos:

En la práctica: las dos peticiones en la misma carga de página. Entre ellas pasan milisegundos, no hay margen para que nada se descarte, y cualquier MISS que veamos solo puede venir de la clave.

Cómo comprobarlo en nuestra propia web

Dos formas, una rápida y una detallada:

Con las cautelas de eviction que acabamos de ver: caché activada, recarga normal en lugar de Cmd+Shift+R, y las dos peticiones dentro de la misma carga de página.

Cross-Origin Storage: recuperar lo compartido sin reabrir el problema

El particionado resolvió un problema de privacidad real y, de paso, dejó otro abierto: hay ficheros que de verdad son idénticos en miles de sitios y que hoy se descargan una y otra vez. Un modelo de IA de 1,35 GB. El canvaskit.wasm de Flutter. Una fuente de emojis con cobertura Unicode completa. React entero.

Cross-Origin Storage (explainer en la WICG) aborda esa raíz desde otro ángulo. En lugar de intentar relajar el particionado de la caché HTTP, propone un almacén distinto y paralelo: una caché content-addressable, donde la clave no es la URL sino el hash SHA-256 del contenido.

Ese cambio de clave es lo que lo hace posible. Si dos sitios necesitan exactamente los mismos bytes, llegan al mismo hash, aunque los sirvan desde URLs distintas. El fichero se guarda una vez y se comparte.

La API imperativa es corta:

// ✅ Good: recuperar de COS con fallback a red
const url = "https://cdn.example/model.bin";
const hash = {
  algorithm: "SHA-256",
  value: "8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4",
};

let blob;
try {
  const handle = await navigator.crossOriginStorage.requestFileHandle(hash);
  blob = await handle.getFile();
  // Already in the shared store: zero bytes over the network.
} catch (err) {
  if (err.name !== "NotFoundError") throw err;
  blob = await fetch(url).then(r => r.blob());
}

Y guardar, declarando quién puede leerlo. Encadenado al bloque anterior, esto es lo que convierte el fallback en una descarga que solo ocurre una vez:

const handle = await navigator.crossOriginStorage.requestFileHandle(hash, {
  create: true,
  origins: "*",
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();

Cómo evita reabrir el problema que motivó el particionado

Esta es la parte que hay que mirar con lupa, porque una caché compartida entre orígenes es exactamente lo que los navegadores desmontaron. La propuesta apila tres defensas:

No hay tamaño mínimo de fichero, y la propuesta razona por qué: sería trivial inflar cualquier fichero con relleno hasta pasar el umbral.

Qué soluciona, y qué no, respecto a la eviction

Conviene no presentar COS como almacenamiento permanente, porque no lo es. Sigue siendo una caché y sus entradas se pueden ir:

Lo que sí elimina COS es la duplicación. Al indexar por hash, el mismo fichero deja de tener N copias, una por partición, y pasa a tener una. Eso baja la presión de eviction global, que era la segunda de las dos vías por las que el particionado sube los fallos de caché. No la elimina, la alivia: menos entradas compitiendo por el mismo disco significa que lo que hay dentro sobrevive más tiempo.

Las cuatro formas de integrarlo

Además de la API imperativa, la propuesta define tres integraciones declarativas, todas apoyadas en integrity para que el hash sea verificable:

<!-- Solo same-site -->
<script src="framework.js" integrity="sha256-abc…" crossoriginstorage></script>

<!-- Global -->
<script src="lib.js" integrity="sha256-def…" crossoriginstorage="*"></script>

<!-- Lista explícita de orígenes -->
<link
  rel="stylesheet"
  href="style.css"
  integrity="sha256-ghi…"
  crossoriginstorage="https://a.example https://b.example"
/>

Con import attributes, para módulos:

const module = await import("resource.ext", {
  with: { integrity: "sha256-abc…", crossOriginStorage: "*" },
});

Y con un modificador CSS para @font-face, que es donde más sentido tiene por el caso de las fuentes:

@font-face {
  font-family: "Popular";
  src: url("font.woff2" integrity("sha256-def…") cross-origin-storage(*));
}

Probarlo hoy

Estado real, sin adornos: COS es una propuesta en la WICG y no está implementada de forma nativa en ningún navegador. En Chrome Platform Status figura como Proposed, así que no hay flag que activar ni canal donde probarlo, tampoco en Canary. Lo que sí se puede hacer es probar el flujo completo con un polyfill, y eso ya dice bastante sobre si el modelo mental encaja con nuestro proyecto.

La vía práctica es la extensión Cross-Origin Storage, que inyecta navigator.crossOriginStorage en todas las páginas. No hace falta Canary ni activar ninguna flag: es una extensión normal, y existe para los tres motores.

NavegadorInstalación
Chrome y derivados de ChromiumChrome Web Store
Firefox, escritorio y AndroidFirefox Add-ons
Safari en macOS, iOS y iPadOSApp Store

El código de la extensión está bajo Apache 2.0. Por debajo usa CacheStorage en el service worker de la propia extensión, que vive fuera del particionado del navegador; por eso los ficheros sí se comparten de verdad entre orígenes distintos.

Conviene saber qué reproduce y qué no. La API imperativa, el modificador CSS y el atributo declarativo siguen la sintaxis del explainer tal cual. Las import attributes no: en lugar de with { crossOriginStorage: "*" } hay que pasar por type="module-cos" y navigator.crossOriginStorage.__non_standard__import().

El detalle que cambia lo que estamos midiendo

Aquí es fácil sacar la conclusión equivocada. El filtro de la Public Hash List viene desactivado por defecto en las opciones de la extensión. Con la PHL apagada, cualquier hash se revela como disponible al resto de orígenes, incluido un fichero de prueba nuestro que no ha visto nadie más.

Es decir: si validamos el flujo con la configuración de fábrica, estamos validando el happy path sin la defensa de privacidad que justifica toda la propuesta. El propio repositorio lo dice sin rodeos: requestFileHandle() permite a cualquier origen preguntar si un hash está cacheado, lo que convierte la presencia en caché en un oráculo de sondeo cross-site para ficheros poco comunes. La PHL existe justamente para cerrar eso.

Para probar el comportamiento realista hay que activarla en las opciones de la extensión. Y entonces la expectativa correcta se invierte: nuestro fichero de prueba debe dar NotFoundError desde el segundo origen. Ese fallo no es un error de montaje, es el gating funcionando.

Cómo montar la prueba

La forma más rápida, sin preparar nada, es usar las páginas de demo de la extensión, publicadas a propósito en dos orígenes distintos: web-ai-community.github.io y googlechrome.github.io. Como github.io está en la Public Suffix List, son sites distintos de verdad. Cargamos el ejemplo de Whisper en uno, dejamos que descargue el modelo, y lo abrimos en el otro: el segundo debería resolverlo desde COS.

Si preferimos controlar el fichero, dos puertos de localhost valen:

npx serve -l 5001 .   # origin A
npx serve -l 5002 .   # origin B

Con la extensión instalada, el flujo a validar es este:

  1. En localhost:5001, descargar un fichero, calcular su SHA-256 con crypto.subtle.digest y guardarlo con origins: '*'.
  2. En localhost:5002, pedir el mismo hash con requestFileHandle() sin create.
  3. Mirar el panel Network del segundo origen. Si COS resuelve, el fichero aparece sin ninguna petición de red asociada.

Con la PHL activada, insisto, el paso 3 debe dar NotFoundError, porque un fichero nuestro nunca va a estar en esa lista. Para ver un hit real con el gating puesto hace falta un recurso que sí esté en la PHL, y ahí es donde sirven las páginas de demo de arriba.

Ese banco de pruebas está aquí montado, con detección de capacidades incluida. Si la etiqueta de arriba dice “COS no disponible”, es que la extensión no está instalada en este navegador y los pasos 2 y 3 quedan deshabilitados:

El hash que calcula es real, sale de crypto.subtle.digest('SHA-256', …) sobre los bytes descargados, y se puede contrastar en el terminal:

shasum -a 256 astropaper-og.jpg
# 07f07701f273c4717cf6f857eda793985a79cd99faeabb824278c854aa9e2990

Para probarlo en un proyecto real hay una segunda opción, vite-plugin-cross-origin-storage. Extrae las dependencias compartidas en chunks direccionados por contenido: si dos sitios compilan la misma dependencia en la misma versión, producen chunks byte a byte idénticos con el mismo SHA-256, que es justo lo que COS necesita para servirlos sin volver a la red.

// vite.config.ts
import { defineConfig } from "vite";
import { cosPlugin } from "vite-plugin-cross-origin-storage";

export default defineConfig({
  plugins: [
    cosPlugin({
      packages: [/^(?:vue$|@vue\/)/],
    }),
  ],
});

Dos detalles prácticos. El plugin solo actúa en build, así que hay que comprobarlo contra vite build && vite preview, no contra el servidor de desarrollo. Y para Nuxt existe nuxt-cos, un módulo que envuelve el plugin. Los dos avisan de lo mismo: son experimentales, el formato de chunk no es estable y no conviene depender de ellos en producción.

Una limitación honesta que documenta la propia extensión: para <script> clásicos, lo que acaba ejecutándose es lo que devuelve la red, así que el atributo siembra la caché pero no ahorra la descarga. Las fuentes vía CSS y los módulos son hoy los casos donde el ahorro se ve de verdad.

Conclusión

Volviendo al caso del principio: el iframe y el padre piden la misma imagen, y en Chrome se descarga dos veces porque el frame site forma parte de la clave. No es un error de configuración nuestro, no se soluciona con Cache-Control y no se soluciona con un CDN mejor.

Lo que sí está en nuestra mano:

Y para lo que no está en nuestra mano, Cross-Origin Storage es la propuesta que merece la pena seguir. Cambiar la clave de “de dónde viene” a “qué es” resuelve el problema sin deshacer el particionado, que es el único camino que no reabre lo que se cerró en 2020.

Referencias


Next Post
Una skill que no sabe nada: la arquitectura de Modern Web Guidance