Make your app work offline with Service Workers

Service Workers can be used to improve loading times and offline support for your sites and web apps. In this tutorial we're going to show you how to progressively enhance a web app with a Service Worker. First we'll cover what is a Service Worker and how its lifecycle works, then we'll show you how to use then to speed up your site (this page) and offer offline content (page 2). 

Then we'll show you how to how to build an app with Service Workers. You'll learn how to set up a bare-bones Worker that will cache and serve static assets (delivering a huge performance boost on subsequent loads), then how to cache dynamic API responses and give our demo app full offline support. First, let's look at what exactly Service Workers are, and how they function.

What is a Service Worker?

So what is a Service Worker? It's a script, written in JavaScript, that your browser runs in the background. It doesn't affect the main thread (where JavaScript usually runs on a web page), and won't conflict with your app code or affect the runtime performance. 

A Service Worker doesn't have direct access to the DOM or events and user interaction happening in the web page itself. Think of it as a layer that sits between the web page and the network, allowing it to intercept and manipulate network requests (e.g. Ajax requests) made by your page. This makes it ideal for managing caches and supporting offline usage.

The Service Worker lifecycle

The life of a Service Worker follows a simple flow, but it can be a bit confusing when you're used to JS scripts just working immediately: 

Installing > Waiting (installed) > Activating > Activated > Redundant

When your page is first loaded, the registration code we added to index.html starts the installation of the Service Worker. When there is no existing Worker the new Service Worker will be activated immediately after installation. A web page can only have one Service Worker active at a time.

If a Worker is already installed, the new Service Worker will be installed and then sit at the waiting step until the page is fully closed and then reloaded. Simply refreshing is not enough because you might have other tabs open. You need to ensure all instances of the page are closed otherwise the new Worker won't activate. You don't have to close the tabs, you can just navigate away to another site and return.

Both install and activate events will only occur once per worker. Once activated, the Service Worker will then have control of the page and can start handling events such as fetch to manipulate requests.

Finally a Service Worker will become redundant if the browser detects that the worker file itself has been updated or if the install or activation fail. The browser will look for a byte difference to determine if a worker script has been updated.

It's important to note you should never change (or rev) the name of your Service Worker. Nor should you cache the worker file itself on the server, as you won't be able to update it easily, though browsers are now smart enough to ignore caching headers.

01. Clone the demo app

Okay, let's get started learning how to build a web app with help from Service Workers. For this tutorial, you're going to need recent versions of Node.js and npm installed on your computer.

We've knocked up a demo app that we will use as the basis for this tutorial (clone the demo app here). The app is a fun little project that fetches the five-day weather forecast based on the user's location. It'll then check if rain is forecast before the end of the day and update the UI accordingly.

It has been built inefficiently (intentionally) using large, unnecessary libraries such as jQuery and Bootstrap, with big unoptimised images to demonstrate the difference in performance when using a Service Worker. It currently weighs in at a ridiculous 4.1MB.

02. Get your API key

In order to fetch the weather data from the API you will need to get yourself a free API key from OpenWeatherMap:

Once you've got your key, open up index.html and look for the window.API_KEY variable in the <head>. Paste your key into the value:

    window.API_KEY = 'paste-your-key-here';

03. Start the development server

Now we're ready to start working on the project. First of all let's install the dependencies by running:

    npm install

There are two tasks for the build tool. Run npm start to start the development server on port 3000. Run npm run build to prepare the 'production' version. Bear in mind that this is only a demo, so isn't really a production version – there's no minification or anything – the files just get 'revved'.

An algorithm is used to create a hash, such as 9c616053e5, from the file's contents. The algorithm will always output the same hash for the same contents, meaning that as long as you don't modify the file, the hash won't change. The hash is then appended to the filename, so for example styles.css might become styles-9c616053e5.css. The hash represents the file's revision – hence 'revved'.

You can safely cache each revision of the file on your server without ever having to invalidate your cache, which is expensive, or worry about some other third-party cache serving up the incorrect version.

04. Introduce your Service Worker

Now let's get started with our Service Worker. Create a file called sw.js in the root of the src directory. Then add these two event listeners to log the install and activate events:

    self.addEventListener('install', (event) => {
      console.log(event);
    });

    self.addEventListener('activate', (event) => {
      console.log(event);
    });

The self variable here represents the Service Worker's global read-only scope. It's a bit like the window object in a web page.

Next we need to update our index.html file and add the commands to install the Service Worker. Add this script just before the closing </body> tag. It will register our worker and log its current status.

   <script>
     if ('serviceWorker' in navigator) {
       navigator.serviceWorker.register('/sw.js')
         .then(function(reg) {
           if (reg.installing) {
             console.log('SW installing');
           } else if (reg.waiting) {
             console.log('SW waiting');
           } else if (reg.active) {
             console.log('SW activated');
           }
         }).catch(function(error) {
           // registration failed
           console.log('Registration failed with ' + error);
         });
     }
   </script>

Start your development server by running npm start and open the page in a modern browser. We'd recommend using Google Chrome as it has good service-worker support in its DevTools, which we'll be referring to throughout this tutorial. You should see three things logged to your Console; two from the Service Worker for the install and activate events, and the other will be the message from the registration.

05. Activate the Worker

We're going to tell our worker to skip the waiting step and activate now. Open the sw.js file and add this line anywhere inside the install event listener:

 self.skipWaiting();

Now, when we update the Worker script, it will take control of the page immediately after installation. It's worth bearing in mind that this can mean the new Worker will be taking control of a page that may have been loaded by a previous version of your Worker – if that is going to cause problems, don't use this option in your app.

You can confirm this by navigating away from the page and then returning. You should see the install and activate events fire again when the new Worker has been installed.

Chrome DevTools has a helpful option that means you can update your Worker just by reloading. Open DevTools and go to the Application tab, then choose Service Worker from the left column. At the top of the panel is a tick box labelled Update on reload, tick it. Your updated Worker will now be installed and activated on refresh.

06. Confirm changes

Let's confirm this by adding console.log('foo') call in either of the event listeners and refreshing the page. This caught us out because we were expecting to see the log in the console when we refreshed, but all we were seeing was the 'SW activated' message. It turns out Chrome refreshes the page twice when the Update on reload option is ticked. 

You can confirm this by ticking the Preserve log tick box in the Console settings panel and refreshing again. You should see the install and activate events logged, along with 'foo', followed by 'Navigated to http://localhost:3000/' to indicate that the page was reloaded and then then final 'SW activated' message.

07. Track the fetch event

Time to add another listener. This time we'll track the fetch event that is fired every time the page loads a resource, such as a CSS file, image or even API response. We'll open a cache, return the request response to the page and then – in the background – cache the response. First off let's add the listener and refresh so you can see what happens. In the console you should see many FetchEvent logs.

self.addEventListener('fetch', (event) => {
 console.log(event);
});

Our serve mode uses BrowserSync, which adds its own script to the page and makes websocket requests. You'll see the FetchEvents for these too, but we want to ignore these. We also only want to cache GET requests from our own domain. So let's add a few things to ignore unwanted requests, including explicitly ignoring the / index path:

self.addEventListener('fetch', (event) => {
 // Ignore crossdomain requests
 if (!event.request.url.startsWith(self.location.origin)) {
   return;
 }
 // Ignore non-GET requests
 if (event.request.method !== 'GET') {
   return;
 }
 // Ignore browser-sync
 if (event.request.url.indexOf('browser-sync') > -1) {
   return;
 }
 // Prevent index route being cached
 if (event.request.url === (self.location.origin + '/')) {
   return;
 }
 // Prevent index.html being cached
 if (event.request.url.endsWith('index.html')) {
   return;
 }
 console.log(event);
});

Now the logs should be much cleaner and it is safe to start caching.

08. Cache the assets

Now we can start caching these responses. First we need to give our cache a name. Let's call ours v1-assets. Add this line to the top of the sw.js file:

const assetsCacheName = 'v1-assets';

Then we need to hijack the FetchEvents so we can control what is returned to the page. We can do that using the event's respondWith method. This method accepts a Promise so we can add this code, replacing the console.log:

  // Tell the fetch to respond with this Promise chain
 event.respondWith(
   // Open the cache
   caches.open(assetsCacheName)
     .then((cache) => {
       // Make the request to the network
       return fetch(event.request)
         .then((response) => {
           // Cache the response
           cache.put(event.request, response.clone());
           // Return the original response to the page
           return response;
         });
     })
 );

This will forward the request on to the network then store the response in the cache, before sending the original response back to the page.

It is worth noting here that this approach won't actually cache the responses until the second time the user loads the page. The first time will install and activate the worker, but by the time the fetch listener is ready, everything will have already been requested.

Refresh a couple of times and check the cache in the DevTools > Application tab. Expand the Cache Storage tree in the left column and you should see your cache with all the stored responses.

09. Serve from the cache

Everything is cached but we're not actually using the cache to serve any files just yet. Let's hook that up now. First we'll look for a match for the request in the cache and if it exists we'll serve that. If it doesn't exist, we'll use the network and then cache the response.

 // Tell the fetch to respond with this chain
 event.respondWith(
   // Open the cache
   caches.open(assetsCacheName)
     .then((cache) => {
       // Look for matching request in the cache
       return cache.match(event.request)
         .then((matched) => {
           // If a match is found return the cached version first
           if (matched) {
             return matched;
           }
           // Otherwise continue to the network
           return fetch(event.request)
             .then((response) => {
               // Cache the response
               cache.put(event.request, response.clone());
               // Return the original response to the page
               return response;
             });
         });
     })
);

Save the file and refresh. Check DevTools > Network tab and you should see (from ServiceWorker) listed in the Size column for each of the static assets. 

Phew, we're done. For such a small amount of code, there's a lot to understand. You should see that refreshing the page once all assets are cached is quite snappy but let's do a quick (unscientific) check of load times on a throttled connection (DevTools > Network tab). 

Without the Service Worker, loading over a simulated fast 3G network takes almost 30 seconds for everything to load. With the Service Worker, with the same throttled connection but loading from the cache, it takes just under a second.

Check the Offline box and refresh and you'll also see that the page loads without a connection, although we can't get the forecast data from the API. On page 2 we'll return to this and learn how to cache the API response too. 

Next page: use Service Worker to offer online access