tl;dr: Do not cache faulty responses in your service worker.

In trying to make my 1hz(opens new window) app an installable PWA in the most simple way possible, I tried using Jeremy Keith's "Minimal viable service worker"(opens new window). The app loads water.css(opens new window) from jsDelivr(opens new window)'s CDN. Unfortunately, this caused service worker to fail to fetch and the page is displayed without a style sheet.

On the Console, I see:

A Chrome developer console showing a lot of errors. Text version follows this image.

Text version
The FetchEvent for "https://cdn.jsdelivr.net/npm/water.css@2.0.0/out/dark.min.css" resulted in a network error response: an "opaque" response was used for a request whose type is not no-cors
serviceworker.js:22 Uncaught (in promise) TypeError: Failed to execute 'put' on 'Cache': Request scheme 'chrome-extension' is unsupported
    at serviceworker.js:22
The FetchEvent for "https://cdn.jsdelivr.net/npm/water.css@2.0.0/out/dark.min.css" resulted in a network error response: an "opaque" response was used for a request whose type is not no-cors
Promise.then (async)
(anonymous) @ serviceworker.js:16
dark.min.css:1 Failed to load resource: net::ERR_FAILED
serviceworker.js:22 Uncaught (in promise) TypeError: Failed to execute 'put' on 'Cache': Request scheme 'chrome-extension' is unsupported
    at serviceworker.js:22
(anonymous) @ serviceworker.js:22
DevTools failed to load SourceMap: Could not load content for https://1hz.glitch.me/dark.min.css.map: HTTP error: status code 404, net::ERR_HTTP_RESPONSE_CODE_FAILURE

Even worse, this happens intermittently. Sometimes it works, and sometimes it fails[1]. I thought maybe this is a race condition issue... or maybe not. Anyways, I was in a hurry, so I decided to replace the minimum viable service workers with the tried-and-true Workbox(opens new window) (but using the same logic):

/* global workbox */
importScripts(
  'https://storage.googleapis.com/workbox-cdn/releases/5.0.0/workbox-sw.js'
)

const { registerRoute } = workbox.routing
const { StaleWhileRevalidate, NetworkFirst } = workbox.strategies

// HTML pages
registerRoute(
  ({ request }) => request.headers.get('Accept').includes('text/html'),
  new NetworkFirst()
)

// Anything else
registerRoute(() => true, new StaleWhileRevalidate())

Nope, still failed, intermittently. The FetchEvent for ".../dark.min.css" resulted in a network error response: an "opaque" response was used for a request whose type is not no-cors and even Workbox cannot deal with that???? Argh!! Guess I'll have to dig in and fix the service worker then.

This led me to this StackOverflow question: Can a service worker fetch and cache cross-origin assets?(opens new window) The answer tells me to make sure to "call clone() before the final return response executes." Maybe it's a race condition. So I made the change:

-    const fetchPromise = fetch(request);
+    const splittedPromise = originalFetchPromise.then(response => ({
+      original: response,
+      copy: response.clone(),
+    }));
+    const fetchPromise = splittedPromise.then(({ original }) => original);
+    const responseCopyPromise = splittedPromise.then(({ copy }) => copy);
+    const originalFetchPromise = fetch(request);
     fetchEvent.waitUntil(async function() {
-      const responseFromFetch = await fetchPromise;
-      const responseCopy = responseFromFetch.clone();
+      const responseCopy = await responseCopyPromise;
       const myCache = await caches.open(cacheName);
       return myCache.put(request, responseCopy);
     }());

...to no avail. Still erroring out, and page still loads without external CSS applied.

Next, I found this Smashing Magazine article: Leonardo Losoviz (2017), "Implementing A Service Worker For Single-Page App WordPress Sites"(opens new window), and I quote (emphasis mine):

Whenever the resource originates from the website’s domain, it can always be handled using service workers. Whenever not, it can still be fetched but we must use no-cors fetch mode. This type of request will result in an opaque response, so we won’t be able to check whether the request was successful; however, we can still precache these resources and allow the website to be browsable offline.

+    const fetchOptions =
+      (new URL(request.url)).origin === self.location.origin
+        ? {}
+        : {mode: 'no-cors'};
-    const fetchPromise = fetch(request);
+    const fetchPromise = fetch(request, fetchOptions);

Again it doesn't help. Worse, it makes more requests fail. The app is more broken.

It's time for my special attack: console.log() the heck out of this service worker.

Console.log showing errors. Among the logging lines it shows that the service worker is caching a response with ok: false

What's that? We're putting faulty responses into the cache?

     fetchEvent.waitUntil(async function() {
       const responseFromFetch = await fetchPromise;
       const responseCopy = responseFromFetch.clone();
+      if (!responseCopy.ok) return;
       const myCache = await caches.open(cacheName);
       return myCache.put(request, responseCopy);
     }());

...and the white unstyled pages are nowhere to be seen again. Service workers are indeed rocket science.(opens new window)


  1. In hindsight, I reckon that it fails around 20% of the time, making debugging this extremely frustrating. ↩︎