How to Cache Opaque Responses Safely in a Service Worker
This guide covers a cross-origin pitfall in Service Worker Caching Strategies, part of Advanced Caching Strategies & CDN Architecture. When a page requests a cross-origin resource without CORS — an image from a third-party CDN, a script tag without crossorigin, a font from another host — and a service worker intercepts it, the worker receives an opaque response. It can pass the response to the page and store it in Cache Storage, but it cannot read its status, headers or body.
Two consequences make opaque responses dangerous to cache. First, the worker cannot tell success from failure: a 404 or 500 looks the same as a 200, so a cache-first strategy may store an error and serve it indefinitely. Second, browsers pad opaque responses' reported sizes to prevent cross-origin size leaks — Chromium counts each opaque entry as several megabytes against the origin's storage quota — so caching a few hundred third-party images can exhaust quota and evict your important caches.
Rapid Diagnosis
- Inspect Cache Storage entries. In DevTools, cached cross-origin responses with type "opaque" are the ones to examine.
- Check quota usage. Application → Storage shows usage; a few hundred megabytes from a modest number of entries indicates opaque padding.
- Check for cached failures. Broken images that persist across reloads even when the third-party host is up suggest a cached opaque error.
- Check request modes.
<img>withoutcrossorigin, CSSurl()to other origins and classic<script>tags produce no-cors requests.
Root Cause Analysis
1. Runtime caching rules that match cross-origin URLs. A rule like "cache all images cache-first" catches third-party images too.
2. No-cors requests by default. Elements fetch cross-origin resources without CORS unless marked with crossorigin.
3. Status checks that fail open. Code that caches when response.ok is false-y does not apply to opaque responses (status 0), and code that checks response.type is rare.
4. Padding by design. Browsers intentionally overstate opaque sizes; quota fills much faster than real bytes suggest.
Step-by-Step Resolution
1. Request cross-origin assets with CORS where the server allows it
Add crossorigin="anonymous" to images, scripts and preloads from hosts that send Access-Control-Allow-Origin, so the worker receives readable CORS responses.
<img src="https://images.example-cdn.com/p/42.avif" crossorigin="anonymous" width="800" height="800" alt="">
<!-- trade-off: if the host does NOT send CORS headers, adding crossorigin makes
the image fail to load entirely. Verify the header before changing markup. -->
2. Exclude opaque responses from cache-first strategies
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
registerRoute(({ request, url }) => request.destination === 'image' && url.origin === location.origin,
new CacheFirst({ cacheName: 'images', plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 86400, purgeOnQuotaError: true }),
] }));
// trade-off: limiting cache-first to same-origin images leaves third-party
// images to the HTTP cache. That is usually fine — they rarely matter offline.
3. If you must cache opaque responses, use a revalidating strategy with tight limits
StaleWhileRevalidate with statuses: [0, 200] refreshes entries on every use, so a cached error is replaced on the next successful fetch. Add strict maxEntries and purgeOnQuotaError.
4. Self-host critical cross-origin assets
Assets important for offline use or LCP — fonts, logos, hero images — are better served from your own origin, where caching is fully under your control.
200
Verification
After changes, inspect Cache Storage: entries for cache-first routes should be type "cors" or "basic", not "opaque". Storage usage should reflect real sizes. Simulate a failing third-party host (DevTools request blocking) and confirm broken responses are not stored or are replaced on recovery.
Worked Example: A Marketplace PWA Running Out of Quota
A marketplace PWA cached all images cache-first, including seller images from a third-party host without CORS. Within a week of use, some devices reported over 2GB of storage, the browser started evicting caches, and the app shell itself was occasionally evicted — making the PWA slower than the website. The team restricted cache-first to same-origin and CORS-enabled image hosts, added crossorigin where the image CDN supported it, set maxEntries with purgeOnQuotaError, and left other third-party images to the HTTP cache. Storage stabilised under 60MB and shell evictions stopped.
Auditing Existing Caches in the Field
Users who installed an older worker may still have opaque entries clogging their storage. On activation of the fixed worker, iterate over runtime caches and delete opaque entries (or delete the old cache names entirely as part of versioning). Monitor navigator.storage.estimate() in RUM for controlled sessions — a falling usage distribution after release confirms the cleanup worked, and a rising one warns that a new rule is catching opaque responses again.
Common Mistakes
- Catch-all image routes. Matching by destination alone includes every third-party image on the page.
- Allowing status 0 with cache-first. Errors become permanent until the cache expires.
- No expiration plugin. Runtime caches grow without bound.
- Adding
crossoriginwithout checking CORS headers. Images then fail to load at all.
Edge Cases
Fonts are always CORS. Browsers fetch web fonts in CORS mode, so font responses are readable if the host sends the header (and fail to load otherwise).
Images via CSS. CSS url() requests cannot set crossorigin per request; they are no-cors when cross-origin. Serve such images from your origin if caching matters.
Videos and range requests. Range responses are not cacheable with simple strategies; avoid caching media in the worker unless you implement range handling.
Quota differences. Browsers allocate quota differently (and Safari evicts more aggressively); limits and purgeOnQuotaError keep behaviour predictable.
FAQ
Why does Chrome report opaque responses as so large?
To prevent sites from inferring the size of cross-origin resources (a privacy and security leak), the browser adds random padding to the quota accounting of opaque responses. The padding is large enough that a few hundred entries consume gigabytes of quota.
Can the service worker convert an opaque response into a readable one?
No. Only the server's CORS headers make a cross-origin response readable. The worker can re-request the resource in CORS mode, which succeeds only if the server allows it.
Does the HTTP cache have the same problem?
No. The HTTP cache stores cross-origin responses normally under its own rules. Opaque padding applies to storage APIs like Cache Storage.
Is it ever reasonable to cache opaque responses?
For a small number of non-critical resources needed offline, with revalidation and strict limits, yes. As a general strategy, no.
How do I check response types in my own code?
response.type is 'opaque' for no-cors cross-origin responses, 'cors' for CORS responses and 'basic' for same-origin ones. Check it before cache.put in custom workers.
What happens when quota is exceeded?
Writes fail, and the browser may evict entire origins' storage under pressure. purgeOnQuotaError lets Workbox clear designated caches first so critical ones survive.
Related
- Debugging service worker cache misses in production — inspecting what is cached.
- Self-hosting third-party scripts — removing cross-origin dependencies.
- Image CDNs and fetchpriority — image hosts and CORS headers.