skip to content

Your app stores cross-origin assets fetched with `mode: 'no-cors'` in the browser's Cache API and starts hitting `QuotaExceededError` even though the files are small. What is an opaque response, and why does storing them behave this way?

level: seniorimportance: should knowfreq 42%

answer

  1. status 0, headers empty, body unreadable
  2. the browser can read it, your script cannot
  3. measuring size would leak cross-origin length
  4. accounting is deliberately inflated
  5. CORS or self-host is the real fix

basics

~20 s

A no-cors cross-origin fetch yields an opaque response: status 0, no readable headers or body. Browsers pad its recorded storage size — in Chromium, to roughly 7 MB apiece — so a handful of small opaque entries can exhaust an origin's quota.

solid answer

~50 s

When you fetch a cross-origin URL with `mode: 'no-cors'` and the server sends no CORS headers, the browser hands your script an opaque `Response`: `type` is `'opaque'`, `status` is `0`, `ok` is `false`, the headers list is empty and the body cannot be read. You can still `cache.put()` it and later return it from a fetch handler, so images and fonts work — but `cache.add()` refuses it, because status 0 is not ok. Two things then bite. First, an opaque response is indistinguishable from a failure: a 404 or an error page caches exactly like a success, and your offline experience quietly serves it. Second, browsers deliberately pad the storage size attributed to opaque entries so that a script cannot learn a cross-origin resource's real length by measuring quota — Chromium's documented padding is around 7 MB per entry. A few dozen tiny icons can therefore blow the origin's quota.

code

javascript · 15 lines
javascript
async function cacheOpaque(url) {
  const req = new Request(url, { mode: 'no-cors' });
  const res = await fetch(req);

  console.log(res.type, res.status, res.ok); // 'opaque' 0 false
  console.log([...res.headers].length);      // 0 - nothing readable

  const cache = await caches.open('third-party');
  // await cache.add(req);  // rejects: status 0 is not an ok status
  await cache.put(req, res); // put has no status check

  const { usage } = await navigator.storage.estimate();
  console.log('reported usage after one small icon:', usage);
}
cacheOpaque('https://cdn.example.com/logo.png');

go deeper

for a junior

Know that a no-cors cross-origin fetch returns a response your script cannot read — status 0, no headers, no body — and that it must be stored with put(), not add().

for a middle

Explain that the browser inflates the recorded storage size of opaque entries, and say why: unpadded accounting would let a script measure a cross-origin resource's length.

for a senior

Diagnose the symptom end to end — small assets, huge reported usage, QuotaExceededError — and argue that enabling CORS or self-hosting is the fix rather than budgeting around the padding.

for a principal

Own the third-party asset policy: which origins are allowed into the offline precache at all, what the failure mode is when an opaque entry turns out to be an error page, and who negotiates CORS with the vendor.

## What makes a response opaque A cross-origin `fetch()` in the default `cors` mode fails outright unless the other server opts in with CORS response headers. Setting `mode: 'no-cors'` says "send the request anyway, and give me something I am not allowed to inspect". The result is an **opaque** response: ```js const res = await fetch('https://cdn.example.com/logo.png', { mode: 'no-cors' }); res.type; // 'opaque' res.status; // 0 res.ok; // false res.statusText // '' [...res.headers]; // [] await res.text(); // '' - the body is not readable by script ``` The opacity is a same-origin-policy consequence, not a Cache API rule: the browser fetched real bytes, but your script must not read them. The response is still usable as a *value* — hand it to `event.respondWith()` and the browser renders the image or applies the font, because the browser can see what your script cannot. ## Getting it into a cache `cache.add()` and `cache.addAll()` reject anything whose status is not in the ok range, and an opaque response's status is `0`. So the only way to store one is `put()`: ```js const req = new Request(url, { mode: 'no-cors' }); const res = await fetch(req); await cache.put(req, res); // add() would have rejected here ``` ## Problem one: failures look like successes Because status and headers are hidden, you cannot tell a 200 from a 404, a 500, or a captive-portal login page. All of them arrive as `status: 0`, and `put()` stores all of them without complaint. The failure surfaces much later, when a user goes offline and your service worker confidently serves a cached error page as if it were the logo. There is no way to validate an opaque entry after the fact; the only defences are to avoid `no-cors` where CORS can be enabled on the other origin, and to verify precached third-party assets some other way — for example by loading them through an element and checking that it loaded. ## Problem two: padded storage accounting The surprising one. If opaque responses were accounted at their true byte length, a script could learn a cross-origin resource's exact size simply by caching it and measuring the change in reported storage usage. Size is information about a resource you were explicitly denied. Browsers therefore add **padding**: the size attributed to an opaque cache entry is inflated by a large, resource-dependent amount so the true length cannot be recovered. The padding is an implementation detail and differs between engines; Chromium's documented behaviour has been to attribute roughly **7 MB** to each opaque entry regardless of the real payload. That is fine for a handful of entries and catastrophic for a precache list of a hundred third-party icons: the origin's quota is consumed by padding, and `cache.put()` starts rejecting with a `QuotaExceededError` long before the real bytes come close to the limit. The same padding shows up when you inspect usage, which is why measurements look absurd: ```js const { usage, quota } = await navigator.storage.estimate(); // usage jumps by megabytes after caching a 4 KB opaque icon ``` ## How to avoid the problem 1. **Get CORS enabled.** If the third-party origin sends `Access-Control-Allow-Origin`, fetch it in normal `cors` mode. The response is then transparent: real status, readable headers, and unpadded accounting. This is the actual fix and is worth the email to the CDN owner. 2. **Use `crossorigin` on the element.** For assets you control the markup for, a `<script crossorigin>`, `<img crossorigin>` or `<link crossorigin>` triggers a CORS-mode request, so the response that lands in caches is not opaque. 3. **Self-host the asset.** A copy on your own origin is same-origin, transparent, and cheap to cache — often less work than the alternatives for fonts and small icons. 4. **Do not bulk-precache opaque third-party assets.** If they must be opaque, cache them lazily on first use so the padded cost tracks what users actually need, and keep the count small. ## What to say in an interview Name the three parts: opacity is enforced by the same-origin policy; padding exists to close a size-disclosure side channel; and the practical consequence is that the correct fix is to stop producing opaque responses rather than to budget around them.

  • If the body is unreadable, why is caching an opaque response useful at all?
    Because the consumer is the browser, not your script. Returning an opaque response from a service worker's `fetch` handler lets the browser decode and render the image, apply the font, or execute the script — it can see the bytes your code cannot. The response is opaque to JavaScript, not to the rendering engine.
  • Why does `cache.add()` reject an opaque response while `cache.put()` accepts it?
    `add()` validates the response before storing and requires an ok status; an opaque response reports status 0, which fails that check — a deliberate guard against silently precaching failures you cannot inspect. `put()` performs no status check, so it stores whatever you hand it, leaving the judgement to you.
  • How would you detect that padded opaque entries are what is exhausting your quota?
    Compare the real byte size of what you cached against `navigator.storage.estimate()`, which reports usage including padding. A cache holding a few hundred kilobytes of icons that reports hundreds of megabytes is conclusive. Then confirm by re-fetching the same assets in CORS mode and watching the reported usage collapse.
  • Does the padding apply to same-origin responses too?
    No. Padding exists purely to hide the length of a resource your script was not allowed to read. Same-origin and CORS-enabled cross-origin responses are fully readable by script, so their sizes disclose nothing new and are accounted at their real length.

saying these in an interview costs you the question

  • Thinks an opaque response can be read with response.text() after caching
  • Assumes a cached opaque entry proves the fetch succeeded
  • Believes cache size always equals the bytes downloaded
  • Says cache.add() works for no-cors cross-origin requests
  • Treats mode: 'no-cors' as a way to bypass CORS restrictions

context