What you should know before making a service worker

Service workers have arrived and they are awesome. They let you create excellent fallback behavior to provide users with seamless offline experiences. With service workers, websites are a step closer to giving users a native app experience in the web browser. With the help of service workers, Lucidchart can save changes, open documents, and even create documents, all without an internet connection. In the process of making Lucidchart an offline application, I learned some tips and pitfalls that will hopefully help you when making your own web app.

A warning

The service worker can act as a man in the middle to all network requests. So when implementing your service worker logic, tread carefully. If there are major bugs in a service worker, it can prevent a user from accessing your website. The user would have to uninstall the service worker to fix the issue, and  they probably wouldn’t know they should do that nor know how to do it. For this reason, I highly recommend having plenty of unit tests to verify your service worker does the right thing. Have testability in mind from the start—don’t treat it as something to just add in later.

navigator.onLine isn’t reliable

navigator.onLine isn’t always reliable. It will either say the browser is offline when it has an internet connection or say there is one when no connection is present. So the best thing to do is just try all requests and gracefully handle errors for the requests that fail. This paradigm not only handles requests when offline but also allows your web app to handle failures if your server goes down.

The right way

try {
    const response = await fetch(request);
    // check if the response is valid since fetch doesn’t throw if the server
    // gives back a non 200-299 status code
    if (shouldAcceptResponse(response)) {
        return response;
    }
} catch {
    // catch network errors
}

// fallback logic goes here

The wrong way

if (navigator.onLine) {
    return fetch(request);
} else {
    // fallback logic
}

Whether or not you accept a response will depend in part on the resource being requested. For some requests a 404 may be passed onto the user, or for other requests the service worker may pass back a placeholder, such as a document created offline. There are a few responses that should always be accepted.

function shouldAcceptResponse(response) {
    return response.status !== 0 && !(response.status >= 400 && repsonse.status < 500) || 
        response.type === “opaque” || 
        response.type === “opaqueredirect”;
}

A status code of 0 usually means there was an error getting the response. The exception is when the type is opaque or opaqueredirect. They have a status of 0 even though the request succeeded. This is because the browser is hiding details about the request from the JavaScript, but the service worker still needs to be able to pass the response through. It is also a good idea to report back 400s error codes. These codes mean the server got the request and handled it, but the client sent bad or out-of-date information. Errors in the 500 range should an unexpected error on the server and the service worker should handle the response as a backup if the server is down.

One modification I have in our code that I didn’t include here was a short time-out on the fetch request when navigator.onLine is false. Some computers would not immediately give an error response when offline. Instead fetch would hang for about a minute before finally failing. Having a short time-out when the browser thought it was offline was a good compromise for us for not entirely trusting navigator.onLine.

Your service worker needs to initialize offline

In our service worker, we needed to retrieve some user information to properly configure the offline experience. This involved communicating with the server when the service worker is initialized. The issue with that process is the service worker may be loaded from memory when offline, causing the initial request to fail. Our solution was to store the last result for the requested information in IndexDB with a timestamp. That way when the service worker first started up, it could pull from IndexDB. If the data was out of date, it would update it. If updating failed, it could just return the cached value. Caching also helped solve another issue we ran into where some users would be making the request on startup multiple times a minute. This put an unexpected load on the user service. Caching the initial request reduced that load.

Small service worker code

One thing you may note is that the browser will ask, many times, for the service worker code. It will check anytime a page is loaded. This means your service worker should be small. You should put the bulk of your service worker into a separate file and then use importScripts in the main service worker file to import the rest.

importScripts(["bulk-of-service-worker.js?version=1.0"]);

 

It is also important to include the version of your service worker as part of the URL. The browser compares the content of the new service worker code with the content of the version it is running. If the content matches, it does nothing, but if it is different, the browser installs the new service worker. Without the version number in the URL, the browser will not know when the content of bulk-of-service-worker.js changes, causing the browser to keep the old service worker even when there is a newer version available.

Fetching fast

A service worker will intercept all network requests from HTML pages in its path and all subpaths. As a result, if the service worker gets hung up responding to requests, your entire website slows down. Do not perform a long action, such as fetching additional info from the server, before every request. If such information is needed, you should request that information when the service worker is loaded and store that information in memory to be used by the fetch handler. Don’t wait for the information in fetch handler. This means some requests may not have the correct info when responding to requests. In our case, we need to know if the user has enabled offline mode or not. When enabled, the service worker will load user info when offline. When it isn’t enabled, the user will see custom error pages letting the user know they can enable offline functionality. What mode the user is in may not be present for the first few requests made to the service worker. This is much preferable to having the first few requests take longer after a new service worker is installed. If you aren’t careful and perform a time-consuming action before all requests, it can make the website unresponsive. Of course, some actions may require more time, so use your discretion as far as what actions can take a long time without ruining the user experience.

How the browser cache works with the service worker

It is important to understand how the cache interacts with the service worker. In Chrome the image’s cache, used by any requests made from an img tag, happens before the service worker. So if an image is already on the page and another img tag requests the same image, the service worker cannot intercept the request. The HTTP cache happens after the service worker. So if you request a URL available in the HTTP cache, the service worker will intercept the request. If you then make the request from the service worker, the HTTP cache will handle the request.

Whitelist cache approach

When writing your app, be sure to think about when unneeded cache entries will be removed. It won’t be done for you. When you put things in the cache, you don’t specify a max age. It is there until you either explicitly take it out or when the browser decides to clean up your cache once your application is running low on storage space. To keep from running low on space, you should clean up the cache regularly. A good place to include this cache cleanup code is in the activate message.

self.addEventListener(‘activate’, (event) => {
    event.waitUntil(cleanCache());
});

To avoid accidentally keeping files in the cache that are no longer needed, we take a whitelist approach to the cache. So instead of trying to identify what should be removed, we identify every request we want to stay and then remove all other requests. That way, outdated requests will be removed, as it isn’t possible to forget to remove them. It will be removed simply because the request wasn’t marked as needed. This is like using a garbage collector rather than having to manually release a resource when it is no longer needed.

Separate essential offline resources and optional ones

In the install step for a service worker, you can download the resources needed by the service worker to operate. In our app, there is potentially a lot of documents and other data to download for offline mode but only some JavaScript, CSS, and HTML are essential for the user to be able to use the app offline. We split the data into high priority and low priority actions. We show a progress bar for downloading the high priority actions after the user enables offline mode. The low priority actions continue downloading in the background, but it doesn’t keep the user from using the app offline if the documents they care about are already cached.

Service workers are great

Service workers have raised the bar for browser-based applications. You can create entire applications that run offline. You are no longer restricted to websites that require an internet connection. Hopefully this article was useful to you for when you make your own offline enabled website and will help you avoid some of the problems I encountered.

1 Comment

  1. Dmitry PashkevichFebruary 14, 2019 at 6:30 pm

    Great tips, thanks James!

Your email address will not be published.