While testing a progressive web app for one of our clients, I bumped into a suspicious error in the browser console:

DOMException: Quota exceeded.

After browsing the app a few more times, it became clear the error would occur after a small number of images were added to the cache storage by the service worker. Looking in the Chrome DevTools Application tab, the cache storage was indeed over capacity.

Chrome DevTools showing over-capacity cache storage

How could this be? There were only ~15 images in the cache storage. Something was off.

What I found could significantly impact your progressive web app—particularly if you use a CDN on a different domain for your assets.

7 MB you say?

As any curious and knowledge-seeking individual might do, I began a search on the world wide web. The search led me to a Stack Overflow thread with a fascinating nugget of information from Jeff Posnick:

In order to avoid leakage of cross-domain information, there’s significant padding added to the size of an opaque response used for calculating storage quota limits (i.e. whether a QuotaExceeded exception is thrown) and reported by the navigator.storage API. The details of this padding vary from browser to browser, but for Google Chrome, this means that the minimum size that any single cached opaque response contributes to the overall storage usage is approximately 7 megabytes.

Say what? 7 megabytes for a single cached response? This is definitely not acceptable.

Opaque responses

Once the 7-megabyte shock wore off, the next thing to dig into was getting a better understanding of opaque responses. Opaque responses are responses that the browser receives from untrusted origins. Because the origin is not trusted, exposing any information about the response to JavaScript—the size, the response code, etc.— could create a security hole. Browsers obfuscate details about the response which makes them opaque.

In short, you’re going to receive opaque responses when you fetch a cross-origin resource using JavaScript by default. An opaque response has a response.type of "opaque" and a response.status of 0 (as opposed to the well-known 200 success status). Matt Gaunt shares in his Introduction to fetch() article:

With an opaque response we won’t be able to read the data returned or view the status of the request, meaning we can’t check if the request was successful or not.

One reason a fetch request to a cross-origin resource can return an opaque response is not having the proper Cross-Origin Resource Sharing (CORS) HTTP response header. From enable-cors.org:

For simple CORS requests, the server only needs to add the following header to its response: Access-Control-Allow-Origin: *

Aha! This CORS response header must be part of the issue. Pulling up the Chrome DevTools Network tab, though, I found the proper CORS HTTP response header was already in place:

Chrome DevTools showing the access-control-allow-origin HTTP response header

The mystery was still unsolved.

Should opaque responses be cached at all?

Taking a step back, I started looking at the mystery from the service worker’s perspective. The service worker was generated using Workbox. I had implemented a cache-first strategy for the cross-origin image resources. By default, when using Workbox’s cache-first strategy, opaque responses do not get cached. From Workbox’s Handle Third Party Requests guide:

In general, Workbox will not cache opaque responses. Let’s say a developer set up a route with a “cache first” strategy. This response would cache the opaque response and serve it up from that point onwards. The problem is that if that request fails for any reason, Workbox won’t be able to detect this and will continue to serve up the broken response. The user will be in a broken state.

Ah, of course. There is no way to tell if it was a successful 200 response or a 404 error! Having your service worker respond to fetch requests with cached errors using a cache-first strategy will lead to a lot of painful user experiences.

Workbox’s guide shows how you can force caching of opaque responses when using a cache-first strategy:

workbox.routing.registerRoute( 'https://cdn.google.com/example-script.min.js', workbox.strategies.cacheFirst({ plugins: [ new workbox.cacheableResponse.Plugin({ statuses: [0, 200] }) ] }), );

You will notice the statuses array contains a 0 , which forces the cache-first strategy to cache opaque responses. Immediately following this code example, in red-colored letters, a note reads:

Warning: This will cache a response that could be an error and it will not get updated!

I started getting a feeling I’d misconfigured Workbox. I proceeded to review the configuration for the progressive web app in question and confirmed that I had indeed allowed caching of opaque responses. Thinking back on how I got there, I recalled noticing the CDN images were not getting cached initially. A quick search on the internet at that moment led me to this documentation. I remember glancing at the “Caching Based on Status Codes” example and assuming I’d found the solution to my problem.

Debugging this cache storage bloating issue, though, gave me the opportunity to learn more about the risks of caching opaque responses. I quickly removed the forced caching of opaque responses until I could figure out how to properly proceed.

Opt-in to CORS mode

After the Workbox configuration update, I noticed the service worker was no longer caching any of the cross-origin image responses. Since they were still opaque responses, this made sense.

Technically, this solved the issue of cache storage bloating because no images were getting cached. Unfortunately, caching images is one of the main reasons we use a service worker. We don’t typically worry about CORS issues with images requests. But we do have to worry about CORS if the images are fetched by JavaScript instead of by the browser itself.

After some more research, I found another helpful Stack Overflow thread where Mr. Posnick pointed to another detail I was missing.

Because your third-party CDNs all seem to support CORS, you could opt-in to CORS mode for your CSS and image requests via the crossorigin attribute, and the responses will no longer be opaque: <img src='https://cors.example.com/path/to/image.jpg' crossorigin='anonymous'> or <link rel='stylesheet' href='https://cors.example.com/path/to/styles.css' crossorigin='anonymous'>

Sure enough, the crossorigin attribute was absent on the <img> tag for the progressive web app in question. The final piece of the puzzle was discovered. Mystery solved!

Summary