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.

CORS response vs opaque response in the cache Comparison of what a service worker can see and what it costs to cache a CORS-enabled response versus an opaque no-cors response. CORS response vs opaque response in the cache CORS response • status, headers and body readable • Errors can be detected and skipped • Quota usage = real size • Safe for cache-first Opaque (no-cors) response • status reported as 0, nothing readable • Errors cached as if successful • Quota usage padded to megabytes • Only safe with network-first or SWR

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> without crossorigin, CSS url() 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.

Quota used by 300 cached images Bar chart of storage quota consumed by three hundred cached images when stored as CORS responses versus opaque responses. Quota used by 300 cached images 300 CORS images (real size) 24MB 300 opaque images (padded) 2100MB

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.

html
<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

javascript
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.

Strategy choice for cross-origin resources Recommended service worker handling of cross-origin resources depending on CORS support and importance. Strategy choice for cross-origin resources Resource CORS available? Recommended handling Third-party images (decorative) no do not intercept Image CDN with CORS yes cache-first, statuses 200 Fonts (CORS by spec) yes cache-first with expiry Third-party scripts often no do not cache; self-host if critical

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 crossorigin without 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.