Build apps that work offline

For a long time, offline functionality, background synchronisation and push notifications have differentiated native apps from their web counterparts. The Service Worker API is a game-changing technology that evens the playing field. In this tutorial, we'll use it to build a page that can serve up content even while there's no internet connection.

01. An HTTPS server

The easiest way to think about Service Workers is as a piece of code that is installed by a site on a client machine, runs in the background, and subsequently enables requests sent to that site to be intercepted and manipulated. Because this is such a powerful capability, to work with Service Workers in a live environment you need to be running over HTTPS. This ensures they can't be exploited, by making sure the Service Worker the browser receives from a page is genuine. 

For development purposes, however, we can run without HTTPS since http://localhost/ is permitted as an exception to this rule. The simplest way to get started is with the npm http-server package.

npm install http-server -g
http-server -p 8000 -c-1

02. Set up a basic page

There's nothing on the server right now, so let's make a basic page to serve up. We'll create a new index.html file, and when we run the server it will now be accessible at http://localhost:8000.

At this stage, you'll find that if you terminate the HTTP server and refresh the page in the browser, you'll get an error page since the site can't be reached. This is entirely expected since we haven't cached any offline content yet.

<!DOCTYPE html>
<html>
  <head>
  <meta charset="UTF-8" />
  <title>Service Worker</title>
  <script src="site.js"></script>
  <link rel="stylesheet" type="text/css" href="custom.css">
  </head>
  <body>
  <header>
  <h1>Welcome</h1>
  </header>
  <div id="content">
  <p>content here</p>
  <img src="kitty.jpg" width="100%">
  </div>
  </body>
</html>

03. Register a Service Worker

We've now got a fairly unremarkable page running, and it's time to start thinking about implementing a Service Worker. Before we get coding, it's worth taking a moment to understand the lifecycle of Service Workers. 

The process kicks off with the 'registration' of a Service Worker in your JavaScript, which tells the browser to start installing the worker – the first step of its lifecycle. Throughout its lifecycle, a Service Worker will be in one of the following states:

  • Installing: Once a Service Worker has been registered, its installation is typically used to download and cache static content
  • Installed: The worker is theoretically ready for use but does not immediately activate
  • Activating: An installed Service Worker will activate itself if either there is no existing Service Worker, or certain conditions lead the existing one to expire; activation is typically used to clear old files from cached offline content
  • Activated: The Service Worker now has control over the document, and can handle requests
  • Redundant: If the Service Worker failed to install or activate, or if it is replaced by a newer Service Worker

04. Check you're registered

Let's register a Service Worker. This effectively points the browser to the JavaScript file which defines the Service Worker's behaviour. Registration is done using the serviceWorker object which is the entry point to the API. We'll also check the API is actually present in the browser before trying to do so. 

The register() function can safely be called every time the page loads, and the browser will determine whether the Service Worker has already been registered.

if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
  navigator.serviceWorker.register('serviceworker.js', {scope: './'}).then(function(registration) {
  console.log("Service worker registered successfully.");
  }, function(error) {
  console.log("Error registering service worker: " + error);
  });
  });
}

05. Implement Service Worker

Next we need to implement the Service Worker itself. Service Workers can listen for a range of events related to their own lifecycle and activity on the page. The most important ones are install, activate and fetch. 

Let's start by creating a listener for the install event, which triggers once the worker's installation is completed. This enables us to instruct the Service Worker to add some offline content in the current folder to a cache. We also need to name our cache – since old caches can persist, updating/versioning this cache name enables you to serve up newer versions of content later on.

var currentCache = 'demo-cache';
self.addEventListener('install', event => {
  event.waitUntil(
  caches.open(currentCache).then(function(cache) {
  console.log("Adding content to cache.");
  return cache.addAll([
  './index_offline.html',
  './kitty_offline.jpg',
  './custom.css'
  ]);
  })
  );
});

06. Fetch event

Our page will now cache content when loaded, but we need some mechanism to intercept requests and redirect them to this cache. To do this, we need to listen for fetch events, which are triggered when a request such as obtaining our index.html file is made across the network. We then match the request against the cache, and serve up the cached resource if found. Otherwise, we fall back to a Fetch API request to the server.

It's worth at this point noting that we have a heavy dependency on JavaScript Promises to work. These can be a little tricky, so are worth familiarising with if you haven't used them before.

self.addEventListener('fetch', event => {
  event.respondWith(
  caches.match(event.request).then(response => {
  return response || fetch(event.request);
  })
   );
    });

07. Extend fetch event

If you test it out now (terminate the HTTP server and refresh the page), you should find that your page works both online and offline. It's likely, however, that you'll want more intelligent offline behaviour, with different content or functionality available when the server is unavailable. 

To achieve this, we can extend our fetch event response further to check specifically for navigation requests and respond with a different offline page when one is detected. This index_offline.html file can be a variation of your online page, or something completely different, and can also use other resources you've cached such as custom.css.

self.addEventListener('fetch', event => {
  if (event.request.mode === 'navigate') {
  event.respondWith(
  fetch(event.request).catch(error => {
  console.log("Page unavailable. Returning offline content.");
  return caches.match('./index_offline.html');
  })
  );
  } else {
  event.respondWith(
  caches.match(event.request).then(response => {
  return response || fetch(event.request);
  })
  );
  }
});

08. Delete cache

There's one more thing we need. If you now try modifying your offline content, you'll find it doesn't update when you test out your page – you still get the old version! This is because the older files are still cached.

You need to implement something to clean out outdated files from the cache to prevent them being served up. This is done by responding to an activate event and deleting all caches which do not match the name specified in currentCache. You can then add a version number to currentCache each time you modify your offline content, to ensure it is refreshed.

this.addEventListener('activate', event => {
  var activeCaches = [currentCache];
  console.log("Service worker activated. Checking cache is up-to-date.");
  event.waitUntil(
  caches.keys().then(keyList => {
  return Promise.all(keyList.map(key => {
  if (activeCaches.indexOf(key) === -1) {
  console.log("Deleting old cache " + key);
  return caches.delete(key);
  }
  }));
  })
  );
});

This article was published in Web Designer magazine issue #268. Subscribe now.

Read more: