loke.dev
Header image for What Nobody Tells You About Opaque Responses: Why Your Service Worker Is Silently Consuming Gigabytes of 'Ghost' Storage

What Nobody Tells You About Opaque Responses: Why Your Service Worker Is Silently Consuming Gigabytes of 'Ghost' Storage

Discover how browser-level security mitigations turn tiny cross-origin assets into multi-megabyte 'ghost' files, triggering mysterious storage quota failures in offline-first applications.

· 7 min read

I was looking at a dashboard for a production PWA last week when I noticed something that didn't make sense. The application's total asset size—all the JS, CSS, and some UI icons—was barely 3 megabytes. Yet, the navigator.storage.estimate() call was reporting that the origin was consuming nearly 400 megabytes of disk space on several users' devices.

If you’ve ever built an offline-first application, you know the drill: you cache your assets, you handle your service worker lifecycle, and you hope for the best. But there is a silent, invisible tax being levied on your users' storage that isn't documented in most "Introduction to Service Workers" tutorials. It’s called the Opaque Response padding, and if you aren’t careful, it will bloat your storage usage by a factor of 100x or more.

What is an Opaque Response, anyway?

When you fetch a resource from a different origin—say, a Google Font, a CDN-hosted image, or a library from Unpkg—the browser is naturally suspicious. For security reasons, if that cross-origin server doesn't explicitly grant your site permission via CORS (Cross-Origin Resource Sharing), the browser still allows you to "use" the resource, but it refuses to let your JavaScript look inside it.

This is an opaque response.

In your Service Worker, it looks like this:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('my-cache').then(async (cache) => {
      const response = await fetch(event.request);
      
      // If this is a cross-origin request without CORS headers,
      // response.type will be 'opaque'
      console.log(`Response type: ${response.type}`);
      
      await cache.put(event.request, response.clone());
      return response;
    })
  );
});

When response.type is opaque, the status code is 0 (not 200), and the body is hidden. You can display an opaque image in an <img> tag or execute an opaque script in a <script> tag, but you can’t read the bytes via response.text() or response.json().

The "Ghost" Storage: Why 10KB becomes 7MB

Here is where the "nobody tells you" part comes in. To prevent a very specific type of security vulnerability known as a side-channel attack (like Spectre), browsers deliberately obfuscate the size of opaque responses when they are stored in the Cache Storage API.

If a browser told you exactly how many bytes an opaque resource took up in storage, a malicious site could use that information to sniff out data from a different origin. By checking how much of your storage quota was consumed, a script could determine if a user was logged in or what specific private data was returned in a cross-origin response based on the file size.

To stop this, modern browsers (Chromium-based ones specifically) add padding.

In Chrome, every single opaque response is padded to a minimum of approximately 7 megabytes.

Think about that. If you cache a tiny 5KB transparent PNG from a third-party CDN that doesn't have CORS enabled, your Service Worker just "charged" the user 7,000KB of their storage quota. If you cache 50 of those icons, you’ve just vanished 350MB of disk space for a handful of tiny files.

Visualizing the Bloat

Let's look at how this manifests in code. If you try to calculate your storage usage manually by looking at file sizes, you'll be lied to. You have to use the Storage Manager API to see the "truth."

async function checkTheDamage() {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const { usage, quota } = await navigator.storage.estimate();
    
    const usageInMB = (usage / (1024 * 1024)).toFixed(2);
    const quotaInMB = (quota / (1024 * 1024)).toFixed(2);
    
    console.log(`Actual Storage Used: ${usageInMB} MB`);
    console.log(`Total Quota: ${quotaInMB} MB`);
    
    // If usageInMB is high but your assets are small, 
    // you likely have an Opaque Response leak.
  }
}

If you run this in a PWA that caches 20 or 30 third-party images without CORS, you’ll see the usage climbing into the hundreds of megabytes. Eventually, you’ll hit the QuotaExceededError, and your Service Worker will stop being able to cache anything at all—even your own critical app shell.

How to identify an Opaque Response in the wild

You can usually spot these in the Network tab of your DevTools. Look for requests where the status is (disk cache) or (service worker) and the type is fetch.

But the most reliable way is to check the response object itself within your Service Worker code. I often use a helper function during development to flag these "heavy" assets:

function isOpaque(response) {
  return response.type === 'opaque';
}

// Inside your fetch event listener
if (isOpaque(response)) {
  console.warn(
    `Caching opaque response for ${event.request.url}. ` +
    `This will consume ~7MB of padding!`
  );
}

The Solution: Getting rid of the padding

You can't "fix" the padding itself; it's a browser security feature. Instead, you have to stop the responses from being opaque in the first place. You have three real options.

1. Enable CORS on the Source (The Gold Standard)

The best way to fix this is to make the request not opaque. This requires the server hosting the asset to send the Access-Control-Allow-Origin header.

When you fetch the asset, you must also request it with CORS mode:

// Requesting with CORS
fetch('https://third-party-cdn.com/logo.png', {
  mode: 'cors'
});

If the server supports CORS, the response.type will be cors. CORS-enabled responses are not padded. If the file is 10KB, it takes up 10KB.

2. Use the crossorigin Attribute in HTML

If you are using Workbox or a similar library that automatically caches assets found in your HTML, make sure your HTML tags have the crossorigin attribute. Without this, the browser defaults to no-cors for many tags (like <img> and <link>), leading to opaque responses in your cache.

<!-- This results in an opaque response (padded) -->
<img src="https://cdn.example.com/huge-icon.png">

<!-- This results in a CORS response (not padded) -->
<img src="https://cdn.example.com/huge-icon.png" crossorigin="anonymous">

3. Avoid Caching Cross-Origin Assets Entirely

Sometimes, you don't actually *need* that third-party asset for the app to function offline. If you can't get the provider to enable CORS, the safest move is simply to avoid caching it.

I’ve seen developers use a "Cache First" strategy for everything, which is a recipe for storage disaster. Instead, filter your cache logic:

const DESTINATIONS_TO_CACHE = ['script', 'style', 'document', 'image'];

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  const isExternal = url.origin !== self.location.origin;

  event.respondWith(
    caches.open('dynamic-assets').then(async (cache) => {
      const response = await fetch(event.request);

      // Don't cache if it's external AND opaque
      if (isExternal && response.type === 'opaque') {
        return response; // Return to browser, but don't .put() in cache
      }

      // Otherwise, proceed with caching
      if (response.ok) {
        cache.put(event.request, response.clone());
      }
      
      return response;
    })
  );
});

The Workbox Perspective

If you’re using Workbox (and you probably should be), they’ve tried to warn us about this for years. If you use a StaleWhileRevalidate or CacheFirst strategy, Workbox will cache opaque responses by default, but it will log a warning in the console.

If you are absolutely certain you want to cache an opaque response and you're okay with the 7MB tax, you have to explicitly tell Workbox it's okay:

import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

registerRoute(
  ({ url }) => url.origin === 'https://third-party-cdn.com',
  new CacheFirst({
    cacheName: 'external-assets',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200], // 0 is the status for opaque responses
      }),
    ],
  })
);

Warning: If you do this for a list of 100 product images, you are going to eat up 700MB of your user's storage. On a mobile device with limited space, the browser will likely purge your entire site's storage to reclaim room for the OS.

Why don't all browsers do this?

It's actually a bit of a moving target. While Chromium (Chrome, Edge, Brave) uses the ~7MB padding, Firefox uses a different approach. Firefox's padding is also significant but can vary. The Safari/WebKit implementation has historically been less transparent about its exact padding math, but the principle remains the same: Cross-origin + No CORS + Cache API = Huge Storage Bloat.

The reason this isn't widely discussed is that most developers test on high-end MacBooks with 1TB of NVMe storage. They don't notice 400MB of "ghost" data. But for a user on a budget Android phone with 32GB of total storage, that 400MB is the difference between your app working and it being uninstalled to make room for a system update.

A final sanity check

If you're maintaining an existing PWA, go to your site, open the DevTools, and run await navigator.storage.estimate().

If the usage value is significantly higher than the sum of the assets listed in your "Cache Storage" tab, you've found the ghost. Look through your cached items for any entry where the "Content Length" is 0 or "Type" is opaque.

The fix isn't usually a code change in the service worker itself—it's a configuration change on your CDN or adding a crossorigin attribute to your HTML tags. It's a small change, but your users' hard drives will thank you.