Skip to content

HTTP cache partitioning: the same image, downloaded twice

Published:

Someone brings me a case that looks trivial and isn’t. A page on siteb.com embeds an iframe from sitea.com. Both of them, the iframe and the parent page, request exactly the same image: https://imageurl.com/myImage.jpg. The iframe asks first, then the parent. The question is simple: is the second request served from cache?

The correct answer is “it depends on the browser”, and that’s precisely the interesting part. Chrome downloads it twice. Firefox and Safari, only once. Same HTML, same headers, same server, different outcome.

Nobody has a bug here. It’s a different design decision about how the cache is indexed, and it’s worth understanding because it affects things we take for granted every day.

Why the cache stopped being shared

For years the HTTP cache was indexed by a single identifier: the resource URL. If cdn.example/jquery.js was already on disk, it didn’t matter which site asked for it; it came from disk. That was the argument behind public CDNs: the more sites served jQuery from the same URL, the more likely it was that whoever was browsing already had it downloaded.

The problem is that a global cache is a perfect side channel. If I can measure how long a resource takes to resolve, I can work out whether it was already on the disk of whoever is browsing, and from there work out where they’ve been. The Chrome team documented three concrete attacks:

The browsers’ answer was to partition: the cache stops being one global table and becomes a collection of isolated compartments. Safari did it first, around 2013. Chrome rolled it out from version 86, in late 2020. Firefox enabled it by default in 85, in January 2021.

So far, all three agree. Where they differ is in how many components the key carries.

The cache key, broken down

This is where the whole difference lives. Chrome uses a triple key:

Chrome, since v86
{ top-level site, frame site, resource URL }

Firefox and Safari use a double key:

Firefox and Safari
{ top-level site, resource URL }

In both cases, “site” means scheme://eTLD+1. Subdomain and port don’t enter the calculation, so https://a.example and https://www.a.example land in the same partition.

Let’s apply it to the case. The iframe’s request:

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

And the parent’s, the same URL from the top-level document:

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

Let’s look closely. In Firefox and Safari the two keys are identical character for character, so the second request is a hit and the bytes come from disk. In Chrome the second component changes, sitea.com against siteb.com, so they are two separate partitions and the image is downloaded twice within the same page load.

In the demo we can request the image from each context and see the resulting key, which partitions get created and how many bytes end up travelling over the network. Let’s switch browsers to compare. It’s a simulation: no real request is made and the browser cache is never touched, the outcome is computed by applying each engine’s documented cache key model.

Why Chrome isolates more

The double key assumes that whoever publishes the top-level page trusts the frames they embed. That’s a reasonable assumption in many cases and false in quite a few: a third-party iframe is somebody else’s code running inside our page.

With a double key, that third-party iframe shares a cache partition with our document and with every other frame on the page. That reopens a reduced version of the original attack: instead of spying across sites, it spies between frames on the same site. An embedded ad can probe what the containing document has cached.

Chrome put the frame site into the key precisely to close that. In its own experiments the performance difference between double and triple keying turned out to be small, so they kept the extra isolation. Firefox and Safari went with the simple model: less isolation between frame and top-level, better hit rate in this particular case.

Neither position is free. Chrome pays in cache misses; Firefox and Safari pay in attack surface between frames. It’s worth being clear that this is a deliberate trade-off and not an oversight.

Nested iframe chains

The case gets more complex with more than one level: a.example embeds b.example, which in turn embeds c.example, and it’s c.example that requests the resource.

Intuition says the key should carry the whole chain. It doesn’t. Chrome uses the top-frame and the immediate frame making the request, and skips whatever sits in between:

Chrome: a.example > b.example > c.example, c.example requests
(https://a.example, https://c.example, https://x.example/lib.js)
                    ↑ b.example appears nowhere

This has a practical consequence that isn’t obvious: two completely different nesting chains share a partition as long as the endpoints match. And the other way round, moving a resource one level up or down the frame hierarchy changes the partition and invalidates the cache.

Let’s request lib.js from each level of the chain and see what counts and what gets ignored:

What breaks day to day

This isn’t browser theory. These are four assumptions we still carry into real projects:

The second case is the one I keep running into most often, and the cheapest to fix:

❌ Bad: relying on a shared cache that no longer exists
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
✅ Good: serve from our own origin
<script src="/vendor/jquery-3.7.1.min.js"></script>

Chrome measured the aggregate cost of rolling partitioning out. The overall cache miss rate went from 54% to 56%, a 3.6% increase, and the fraction of bytes coming from the network went from 75% to 78%. The impact on First Contentful Paint stayed under 1% at most quantiles, with a typical regression around 0.3%. Small on average, and considerably larger in the specific cases that depended on sharing.

Sharing a partition isn’t enough: eviction

This is where it’s worth slowing down, because it’s the mistake I’ve seen most often while debugging this. A matching key does not guarantee a hit. Sharing a partition is necessary, not sufficient.

In the Firefox and Safari case, both requests land in the same partition. Great. But if the entry is no longer on disk when the second request arrives, it gets downloaded again anyway. And the entry may have disappeared for reasons that have absolutely nothing to do with partitioning:

Partitioning and eviction are two completely independent causes of a MISS, and in the waterfall they look exactly the same. Telling them apart matters because they lead to different fixes: one is addressed with headers and a byte budget, and about the other there is nothing we can do from our code.

We can see it in the demo above with the Between requests control: pick Firefox or Safari, request the image from the iframe, evict the entry and request it again from the parent. The key is identical, the partition is the same, and there’s still a MISS.

Partitioning doesn’t just divide, it squeezes

And here’s where the two causes cross, which is the part that isn’t obvious.

Each partition behaves like a separate cache with its own space inside the same disk. A resource that used to occupy one entry now occupies N, one per combination of sites it gets requested from. The same Google Fonts file can have one copy per site we visit, and in Chrome even several copies within a single site if different frames request it.

The disk budget, meanwhile, did not multiply. So more entries compete for the same space, the cache fills up sooner and LRU starts dropping entries much earlier. Partitioning raises the miss rate through two paths at once: keys that no longer match, and entries that survive less time because there’s more pressure.

It’s worth being precise about the numbers: the figures Chrome published, that 3.6% more misses and 4% more bytes from the network, are the aggregate number from the rollout. They capture the combined effect of both mechanisms and don’t come broken down by cause. When we read “partitioning costs 3-4%”, what we’re reading includes the duplication of copies, not just the change of key.

A note on reproducing it in DevTools

If we want to reproduce the iframe-and-parent case reliably, two warnings:

In practice: both requests in the same page load. Milliseconds pass between them, there’s no room for anything to be evicted, and any MISS we see can only come from the key.

How to check it on our own site

Two ways, one quick and one detailed:

With the eviction caveats we just saw: cache enabled, a normal reload rather than Cmd+Shift+R, and both requests within the same page load.

Cross-Origin Storage: getting sharing back without reopening the problem

Partitioning solved a real privacy problem and, along the way, left another one open: there are files that genuinely are identical across thousands of sites and that today get downloaded over and over. A 1.35 GB AI model. Flutter’s canvaskit.wasm. An emoji font with full Unicode coverage. The whole of React.

Cross-Origin Storage (explainer at the WICG) approaches that root from a different angle. Instead of trying to relax HTTP cache partitioning, it proposes a separate, parallel store: a content-addressable cache, where the key isn’t the URL but the SHA-256 hash of the content.

That change of key is what makes it possible. If two sites need exactly the same bytes, they arrive at the same hash, even if they serve them from different URLs. The file is stored once and shared.

The imperative API is short:

// ✅ Good: retrieve from COS with a network fallback
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());
}

And storing, declaring who may read it. Chained onto the block above, this is what turns the fallback into a download that only happens once:

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

How it avoids reopening the problem that motivated partitioning

This is the part to look at closely, because a cache shared across origins is exactly what browsers dismantled. The proposal stacks three defences:

There’s no minimum file size, and the proposal reasons why: it would be trivial to pad any file until it passed the threshold.

What it fixes about eviction, and what it doesn’t

It’s worth not presenting COS as permanent storage, because it isn’t. It’s still a cache and its entries can go:

What COS does eliminate is duplication. By indexing on the hash, the same file stops having N copies, one per partition, and comes down to one. That lowers global eviction pressure, which was the second of the two paths by which partitioning raises cache misses. It doesn’t remove it, it eases it: fewer entries competing for the same disk means what’s in there survives longer.

The four ways to integrate it

Besides the imperative API, the proposal defines three declarative integrations, all leaning on integrity so the hash is verifiable:

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

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

<!-- Explicit list of origins -->
<link
  rel="stylesheet"
  href="style.css"
  integrity="sha256-ghi…"
  crossoriginstorage="https://a.example https://b.example"
/>

With import attributes, for modules:

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

And with a CSS modifier for @font-face, which is where it makes the most sense given the font case:

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

Trying it today

The plain state of things: COS is a WICG proposal and it isn’t natively implemented in any browser. Chrome Platform Status lists it as Proposed, so there’s no flag to turn on and no channel to try it in, not even Canary. What can be done is running the whole flow through a polyfill, and that already tells us a lot about whether the mental model fits our project.

The practical route is the Cross-Origin Storage extension, which injects navigator.crossOriginStorage into every page. Canary isn’t needed and neither is any flag: it’s an ordinary extension, and it exists for all three engines.

BrowserInstall
Chrome and Chromium-basedChrome Web Store
Firefox, desktop and AndroidFirefox Add-ons
Safari on macOS, iOS and iPadOSApp Store

The extension source is Apache 2.0. Underneath it uses CacheStorage in the extension’s own service worker, which lives outside the browser’s partitioning; that’s why the files really are shared across different origins.

It’s worth knowing what it reproduces and what it doesn’t. The imperative API, the CSS modifier and the declarative attribute follow the explainer’s syntax exactly. Import attributes don’t: instead of with { crossOriginStorage: "*" } you have to go through type="module-cos" and navigator.crossOriginStorage.__non_standard__import().

The detail that changes what we’re measuring

It’s easy to draw the wrong conclusion here. The Public Hash List filter is off by default in the extension options. With the PHL off, any hash is revealed as available to other origins, including a test file of ours that nobody else has ever seen.

Which means: if we validate the flow with the factory configuration, we’re validating the happy path without the privacy defence that justifies the whole proposal. The repository itself says it plainly: requestFileHandle() lets any origin ask whether a given hash is cached, which turns cache presence into a cross-site probing oracle for uncommon files. The PHL exists precisely to close that.

To test realistic behaviour it has to be enabled in the extension options. And then the correct expectation flips: our test file should return NotFoundError from the second origin. That failure isn’t a setup mistake, it’s the gating working.

How to set the test up

The fastest way, with nothing to prepare, is to use the extension’s demo pages, published on purpose across two different origins: web-ai-community.github.io and googlechrome.github.io. Since github.io is on the Public Suffix List, they’re genuinely different sites. We load the Whisper example on one, let it download the model, and open it on the other: the second should resolve it from COS.

If we’d rather control the file, two localhost ports will do:

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

With the extension installed, the flow to validate is this:

  1. On localhost:5001, download a file, compute its SHA-256 with crypto.subtle.digest and store it with origins: '*'.
  2. On localhost:5002, request the same hash with requestFileHandle() without create.
  3. Look at the Network panel on the second origin. If COS resolves, the file appears with no network request attached.

With the PHL enabled, again, step 3 should return NotFoundError, because a file of ours will never be on that list. To see a real hit with gating in place you need a resource that is on the PHL, and that’s where the demo pages above come in.

That test bench is set up right here, feature detection included. If the label above says “COS unavailable”, the extension isn’t installed in this browser and steps 2 and 3 are disabled:

The hash it computes is real, it comes from crypto.subtle.digest('SHA-256', …) over the downloaded bytes, and it can be checked in the terminal:

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

To try it in a real project there’s a second option, vite-plugin-cross-origin-storage. It extracts shared dependencies into content-addressed chunks: if two sites build the same dependency at the same version, they produce byte-for-byte identical chunks with the same SHA-256, which is exactly what COS needs to serve them without going back to the network.

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

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

Two practical details. The plugin only runs at build time, so it has to be checked against vite build && vite preview, not against the dev server. And for Nuxt there’s nuxt-cos, a module wrapping the plugin. Both carry the same warning: they’re experimental, the chunk format isn’t stable and they shouldn’t be depended on in production.

One honest limitation the extension itself documents: for classic <script> elements, what ends up executing is whatever the network returns, so the attribute seeds the cache but doesn’t save the download. Fonts via CSS and modules are today the cases where the saving really shows.

Conclusion

Back to the case we opened with: the iframe and the parent request the same image, and in Chrome it downloads twice because the frame site is part of the key. It isn’t a misconfiguration on our side, it isn’t fixed with Cache-Control and it isn’t fixed with a better CDN.

What is in our hands:

And for what isn’t in our hands, Cross-Origin Storage is the proposal worth following. Changing the key from “where it came from” to “what it is” solves the problem without undoing partitioning, which is the only path that doesn’t reopen what was closed in 2020.

References


Next Post
A skill that knows nothing: the architecture of Modern Web Guidance