
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:

- **History detection.** I load a resource that only exists on one very specific site and I time it. If it comes from cache, whoever is browsing has been there.
- **Cross-site search.** A finer variant: I check whether a particular resource is cached to infer whether a search string appears in someone's private results.
- **Supercookies.** The cache as a persistent identifier. I write a resource carrying a unique ID and read it back from any other site; it survives clearing cookies.

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**:

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

Firefox and Safari use a **double key**:

```text
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:

```text
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:

```text
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.

<figure>
  <iframe
    id="cache-key-demo"
    src="/demos/cache-partitioning-key-en.html"
    width="100%"
    height="720"
    style="border: none; border-radius: 8px; display: block;"
    title="Interactive demo: cache key for the same image requested from an iframe and from the parent page in Chrome, Firefox and Safari"
    loading="lazy"
  ></iframe>
</figure>
<script>
window.addEventListener('message', function (ev) {
  if (ev.data && typeof ev.data.cacheKeyEnH === 'number') {
    var f = document.getElementById('cache-key-demo');
    if (f) f.style.height = (ev.data.cacheKeyEnH + 8) + 'px';
  }
});
</script>

### 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:

```text
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:

<figure>
  <iframe
    id="cache-nested-demo"
    src="/demos/cache-partitioning-nested-en.html"
    width="100%"
    height="560"
    style="border: none; border-radius: 8px; display: block;"
    title="Interactive demo: which part of a nested iframe chain ends up in the cache key"
    loading="lazy"
  ></iframe>
</figure>
<script>
window.addEventListener('message', function (ev) {
  if (ev.data && typeof ev.data.cacheNestedEnH === 'number') {
    var f = document.getElementById('cache-nested-demo');
    if (f) f.style.height = (ev.data.cacheNestedEnH + 8) + 'px';
  }
});
</script>

## What breaks day to day

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

- **Google Fonts and third-party fonts.** The idea that "if it's already cached from another site, it loads instantly" hasn't been true across sites for years. And within a single page, in Chrome, if the font is requested by the document and also by a widget embedded in an iframe, it downloads twice. Chrome's number here is clear: cache misses for third-party fonts went from 21% to 28%, a 33% increase.
- **jQuery and libraries from a shared CDN.** The classic argument for using `code.jquery.com` instead of serving the file from our own domain was the shared cache. That argument no longer exists. What's left is one more connection to open, one more point of failure and one more third party given access to our execution.
- **Ad servers and embedded widgets.** A banner served inside an ad server's iframe doesn't share a cache with our document in Chrome. If the same image or the same script appears in both places, both get downloaded. It's exactly the case we opened with, and it usually goes unnoticed because in the waterfall they look like two requests to different URLs until you look properly.
- **Analytics pixels and tag managers in frames.** Same pattern. The SDK loaded by the frame reuses nothing of what the containing document already loaded.

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

```html
❌ Bad: relying on a shared cache that no longer exists
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
```

```html
✅ 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:

- **LRU or LFU when the cache is full.** The browser has a finite disk budget. When it fills up, it drops entries by age or by usage frequency. Nobody tells us.
- **Expiry from HTTP headers.** A short `Cache-Control: max-age` or an already-past `Expires` makes the entry go stale. This doesn't depend on partitioning: it's HTTP's freshness layer and it works the same in all three browsers.
- **Profile quota pressure.** The browser manages a global per-profile disk budget. If the system is short on space, or if another origin has been writing a lot, the cache gets wiped without asking.

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:

- **Unchecking "Disable cache" isn't enough.** With that box ticked everything shows up as network and the experiment says nothing. But unticking it doesn't guarantee seeing the hit either, because what has to be checked is whether the entry is still alive between the two requests. The signal is in the `Size` column of the Network panel: `(disk cache)` versus a real transferred weight.
- **The interval between the two requests matters.** If we measure both within the same page load, any MISS points to partitioning. If there's a reload in between, or minutes of session pass, or whoever is browsing has kept browsing, the odds go up that the entry was simply evicted. We'd be blaming partitioning for something that is ordinary LRU.

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:

- **The quick one: the Network panel.** We load the page, find the suspicious resource and look at the `Size` column. If it appears twice, once with the real transferred weight and again with a real weight instead of `(disk cache)`, there's the double fetch. It's worth enabling the `Initiator` column to tell which one comes from the document and which from the iframe. It's the same move I make in any audit before looking at anything else.
- **The detailed one: `chrome://net-export/`.** We record a network log while loading the page, open it in [netlog-viewer](https://netlog-viewer.appspot.com/) and search for `SplitCacheByNetworkIsolationKey`. The suffix tells us which group we're in: `Experiment_` means partitioning is active, `Control_` or `Default_` mean it's off. Since v86 the first is the norm, so this mostly serves to confirm that no flag or enterprise policy is changing the behaviour underneath.

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](https://github.com/WICG/cross-origin-storage)) 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:

```js
// ✅ 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:

```js
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:

- **The `origins` field decides visibility.** If omitted, the file can only be retrieved by same-site origins. You can give an explicit list, or set `'*'` to make it global. Visibility can be widened later, never narrowed.
- **The Public Hash List (PHL) with a k-anonymity criterion.** K-anonymity is the property that a piece of data is only revealed if there are at least `k` other cases indistinguishable from it, so revealing it points at none of them in particular. Applied here: for files marked `'*'`, the browser doesn't simply confirm that it has them. It consults a list of verified hashes: only those that appear, byte-for-byte identical, across enough distinct origins get in. If the hash isn't on that list, the answer is `NotFoundError` even if the file is on disk. That kills probing at the root: there's no point asking about a rare, unique hash, because it will never be on the list.
- **GREASE'ing.** Even with everything in order, the browser may return false negatives now and then. A `NotFoundError` stops being a reliable signal, which makes probing at scale impractical. The proposal sets an explicit limit: this must not be done with files so large that downloading them again costs more than what's gained in privacy. Forcing a re-download of a 1.35 GB model to add noise isn't worth it.

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:

- The browser **could delete files automatically**. The explainer leaves this as an open option and gives an LRU-style criterion as an example, without requiring any.
- There's a **quota per writing origin**: if storing a file would push that origin past its limit, the promise is rejected with `QuotaExceededError`. That's the defence against cache flooding, so no site can fill the store on purpose and push out everyone else's resources.
- The firmest part is **user control**: the proposal expects the browser to offer a settings screen showing which files are in COS and which origins have accessed each one, with the option to delete them. Clearing a site's data removes its usage information, and a file left with no origin using it may disappear.

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:

```html
<!-- 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:

```js
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:

```css
@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.

| Browser                        | Install                                                                                                             |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| Chrome and Chromium-based      | [Chrome Web Store](https://chromewebstore.google.com/detail/cross-origin-storage/denpnpcgjgikjpoglpjefakmdcbmlgih)   |
| Firefox, desktop and Android   | [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/cross-origin-storage/)                              |
| Safari on macOS, iOS and iPadOS | [App Store](https://apps.apple.com/us/app/cross-origin-storage/id6788319695)                                         |

The [extension source](https://github.com/web-ai-community/cross-origin-storage-extension) 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`](https://web-ai-community.github.io/cross-origin-storage-extension/) and [`googlechrome.github.io`](https://googlechrome.github.io/samples/cos-demo/). 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:

```bash
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:

<figure>
  <iframe
    id="cos-lab-demo"
    src="/demos/cos-lab-en.html"
    width="100%"
    height="640"
    style="border: none; border-radius: 8px; display: block;"
    title="Test bench for navigator.crossOriginStorage: SHA-256 hash, store and retrieve"
    loading="lazy"
  ></iframe>
</figure>
<script>
window.addEventListener('message', function (ev) {
  if (ev.data && typeof ev.data.cosLabEnH === 'number') {
    var f = document.getElementById('cos-lab-demo');
    if (f) f.style.height = (ev.data.cosLabEnH + 8) + 'px';
  }
});
</script>

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:

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

To try it in a real project there's a second option, [`vite-plugin-cross-origin-storage`](https://github.com/danielroe/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.

```ts
// 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:

- **Stop assuming a shared cache across sites.** That mental model expired years ago. Serving libraries and fonts from our own origin should be the default decision.
- **Look at the `Initiator` in the waterfall.** When a resource shows up twice in the same load, there's almost always an iframe involved.
- **Count frames as cache boundaries**, not just as security boundaries. In Chrome they are.
- **Don't confuse a MISS from partitioning with a MISS from eviction.** Sharing a partition is necessary for a hit, not sufficient, and each cause is fixed somewhere different.

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

- [HTTP cache partitioning](https://developer.chrome.com/blog/http-cache-partitioning), Chrome for Developers
- [HTTP cache partitioning explainer](https://github.com/shivanigithub/http-cache-partitioning), with the double versus triple keying analysis
- [State Partitioning](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/State_Partitioning), MDN
- [Say goodbye to resource caching across sites and domains](https://www.stefanjudis.com/notes/say-goodbye-to-resource-caching-across-sites-and-domains/), Stefan Judis
- [Cross-Origin Storage (COS)](https://github.com/WICG/cross-origin-storage), explainer at the WICG
- [Public Hash List](https://github.com/WICG/cross-origin-storage/tree/main/public-hash-list/implementation), the list implementing k-anonymity gating
- [Cross-Origin Storage extension source](https://github.com/web-ai-community/cross-origin-storage-extension), the polyfill for [Chrome](https://chromewebstore.google.com/detail/cross-origin-storage/denpnpcgjgikjpoglpjefakmdcbmlgih), [Firefox](https://addons.mozilla.org/en-US/firefox/addon/cross-origin-storage/) and [Safari](https://apps.apple.com/us/app/cross-origin-storage/id6788319695)
