Service Workers and a Promise to Catch

I love Service Workers. I've written previously about my work to use them in Thimble. They've allowed us to support all of the web's dynamic loading mechanisms for images, scripts, etc., which wasn't possible when we only used Blob URLs.

But as much as I love them, they add a whole new layer of pain when you're trying to debug problems. Every web developer has dealt with the frustration of a long debug session, baffled when code changes won't get picked up in the browser, only to realize they are running on cached resources. Now add another ultra powerful cache layer, and an even more nuanced debug environment, and you have the same great taste with twice the calories.

We've been getting reports from some users lately that Thimble has been doing odd things in some cases. One of thing things we do with a Service Worker is to simulate a web server, and load web resources out of the browser filesystem and/or editor. Instead of seeing their pages in the preview window, they instead get an odd 404 that looks like it comes from S3.

Naturally, none of us working on the code can recreate this problem. However, today, a user was also kind enough to include a screenshot that included their browser console:

And here, finally, is the answer! Our Service Worker has failed to register, which means requests for resources are hitting the network directly vs. the Service Worker and Cache Storage. I've already got a patch up that should fix this, but while I wait, I wanted to say something to you about how you can avoid this mess.

First, let's start with the canonical Service Worker registration code one finds on the web:

if ('serviceWorker' in navigator) {  
  // Register a service worker hosted at the root of the
  // site using the default scope.
  navigator.serviceWorker.register('/sw.js').then(function(registration) {
    console.log('Service worker registration succeeded:', registration);
  }).catch(function(error) {
    console.log('Service worker registration failed:', error);
  });
} else {
  console.log('Service workers are not supported.');
}

Here, after checking if serviceWorker is defined in the current browser, we attempt (I use the word intentionally) to register the script at /sw.js as a Service Worker. This returns a Promise, which we then() do something with after it completes. Also, there's an obligatory catch().

I want to say something about that catch(). Of course we know, I know, that you need to deal with errors. However, errors come in all different shapes and sizes, and when you're only anticipating one kind, you can get surprised by rarer, and more deadly varieties.

You might, for example, find that you have a syntax error in /sw.js, which causes registration to fail. And if you do, it's the kind of error you're going to discover quickly, because it will break instantly on your dev machine. There's also the issue that certain browsers don't (yet) support Service Workers. However, our initial if ('serviceWorker' in navigator) {...} check should deal with that.

So having dealt with incompatible browsers, and incompatible code, it's tempting to conclude that you're basically done here, and leave a console.log() in your catch(), like so many abandoned lighthouses, still there for tourists to take pictures, but never used by mariners.

Until you crash. Or more correctly, until a user crashes, and your app won't work. In which case you begin your investigation: "Browser? OS? Versions?" You replicate their environment, and can't make it happen locally. What else could be wrong?

I took my grief to Ben Kelly, who is one of the people behind Firefox's Service Worker implementation. He in turn pointed me at Bug 1336364, which finally shed light on my problem.

We run our app on two origins: one which manages the user credentials, and talks to our servers; the other for the editor, which allows for arbitrary user-written JS to be executed. We don't want the latter accessing the cookies, session, etc. of the former. Our Service Worker is thus being loaded in an iframe on our second domain.

Or, it's being loaded sometimes. The user might have set their browser's privacy settings to block 3rd party cookies, which is really a proxy for "block all cookie-like storage from 3rd parties," and that includes Service Workers. When this happens, our app continues to load, minus the Service Worker (failing to register with a DOM security exception), which various parts of the app expect to be there.

In our case, the solution is to add an automatic failover for the case that Service Workers are supported but not available. Doing so means having more than a console.log() in our catch(e) block, which is what I'd suggest you do when you try to register() your Service Workers.

This is one of those things that makes lots of sense when you know about it, but until you've been burned by it, you might not take it seriously. It's an easy one to get surprised by, since different browsers behave differently here, and testing for it means not just testing with different browsers, but also different settings per browser.

Having been burned by it, I wanted to at least write something that might help you in your our of need. If you're going to use Service Workers, you have to Promise to do more with what you Catch than just Log it.