Screenshot offline chrome

When you visit a website without an internet connection in Chrome, you see the offline dinosaur.

No wonder we tend to think of websites as less reliable than mobile applications - we can't use them without the network.

At least, not typically. We could use App Cache and the Cache Manifest to create an offline experience. For a number of reasons, developers have found App Cache difficult to work with.

Luckily, there's a new web standard, Service Worker, that potentially supplants App Cache by providing more granular control over networking in JavaScript, as opposed to manifest files.

For now, we use a service worker to render a simple error page with our own branding when a user attempts to come back to our site without a connection. Keep in mind, the techniques used here are building blocks that can be taken further to enhance functionality.

To do this, we're going to use a service worker to precache the offline assets on the first visit to the site. Later, during a return visit without a network connection, we can use our service worker to render the offline page.

This is possible because Service Worker acts as a liason between your visitor's browser and your servers outside the lifecycle of a page.

Keep in mind also, will be a progressive enhancement. Since service workers are not available in all browsers, this approach won't work for everyone, but the experience won't degrade for those visitors either.

Produce the assets

First we need an offline page. We could simply use an HTML page in the public directory with embedded styles like the generated Rails 404 and 500 pages.

Source: /offline.html

Alternatively, set up a route to a controller action as a dynamic Rails error page.

Add a service worker file

We're going to cache this offline HTML on the client side during their first visit so that it's available later. We can of course add links to external CSS, JavaScript, and images in our offline pages - we just need to remember to cache those resources as well.

(The following assumes Sprockets, so if using something else, we'll need to adjust accordingly where the asset pipeline is concerned.)

The service worker script file must live outside our application.js or other bundled assets. It can live in any path from which Sprockets can load assets, but for now, we'll add a new JavaScript file in app/assets/javascripts/serviceworker.js.

Since it won't be bundled with application.js, we'll need to let our Rails configuration know to precompile our serviceworker separately:

# config/initializers/assets.rb

Rails.application.config.assets.precompile += %w[serviceworker.js]

Declare an 'install' event

Since service workers are event driven, we'll provide callbacks to three key events in the servive worker lifecycle: install, activate, and fetch.

The install event will be invoked just the first time the service worker is requested or any time it is updated and redeployed prior to being activated. Here, we'll precache our offline assets:

var version = 'v1::';

self.addEventListener('install', function onInstall(event) {
  event.waitUntil(
    caches.open(version + 'offline').then(function prefill(cache) {
      return cache.addAll([
        '/offline.html',
        // etc
      ]);
    })
  );
});

event.waitUntil accepts a promise which must succeed for the install event to install the service worker successfully. We use caches.open to return a promise that adds our static offline assets to a named cache associated with our site and the user's browser. The Cache API provides client-side storage for request/response pairs, a lot like a built in HTTP cache.

We can also cache precompiled assets by renaming our serviceworker.js to serviceworker.js.erb and embedding helper methods:

return cache.addAll([
  '/offline.html',
  '<%= asset_path "application.css" %>',
]);

'fetch' or fallback

Our service worker can intercept any external network request from our visitor's browser - even to cross-origin hosts - within the fetch event.

There are a ton of strategies we can employ to give the service worker power to respond to various requests, for our simple offline page

self.addEventListener('fetch', function onFetch(event) {
  var request = event.request;

  if (!request.url.match(/^https?:\/\/example.com/) ) { return; }
  if (request.method !== 'GET') { return; }

  event.respondWith(
    fetch(request).                                      // first, the network
      .catch(function fallback() {
        caches.match(request).then(function(response) {  // then, the cache
          response || caches.match("/offline.html");     // then, /offline cache
        })
      })
  );
});

This code will filter for GET requests to our host. Ignored fetch events will simply proceed to the network. When we may want to provide the offline fallback, we'll then ask then network to fetch the request. If that doesn't resolve, our catch handler will be invoked and attempt to match the request in the cache or simply return our cached offline page.

Clean up during 'activate'

The activate event is useful to clean up old caches, say when the offline page or any of the linked static resources changes.

// var version = "v2::";

self.addEventListener('activate', function onActivate(event) {
  event.waitUntil(
    caches.keys().then(function deleteOldCache(cacheNames) {
      return Promise.all(
        cacheNames.filter(function(cacheName) {
          return key.indexOf(version) !== 0;
        }).map(function(cacheName) {
          return caches.delete(cacheName);
        })
      );
    })
  );
});

If we deploy a service worker with a new version number, the install event will be invoked again to re-cache the static resources for the offline page. During activate, any cache names that don't match the new version number will be removed.

Register that worker

With our service worker event handling in place, we must register the script from the main page. In any file included in application.js:

// app/assets/application.js

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/serviceworker.js');
}

It's important for this script to be available at the scope for which we want the service worker to be active. In other words, registering the service worker at its precompiled path /assets/serviceworker.js, won't be helpful since we won't be able to intercept requests to the root path.

Sprinkle in some middleware

To make this work with the Rails asset pipeline, we can use the serviceworker-rails gem.

# Gemfile

gem "serviceworker-rails"

ServiceWorker::Rails will insert middleware into the Rails stack that we can configure to route requests to bundled assets.

# config/initializers/serviceworker.rb

Rails.application.configure do
  config.serviceworker.routes.draw do
    match "/serviceworker.js"
  end
end

Now, any request to the path /serviceworker.js will match an asset of that name. If your service worker script is in a nested directory, you'd use this instead:

match "/serviceworker.js" => "nested/directory/serviceworker.js"

See the project README for more info on how to configure the middleware.

While you're at it, star the project on GitHub!

Moment of truth

Phew! That took some setup. Our offline page should now be ready for consumption. Try disabling your network connection to test it out. You can use the Network tab in Chrome and Chrome Canary to take your browser offline while Firefox has the Work Offline mode under the File menu.

Screenshot dev tools network offline

To see a working demo of an offline page, check out the Service Worker Rails Sandbox app. You can find its source code on GitHub.

Screenshot offline custom

Debugging

Chrome also provides some useful debugging tools for service workers under the Resources. It's helpful to read up on the Service Worker life cycle since it is treated differently than other JavaScript resources. For example, a hard refresh isn't enough to get your browser to install an updated service worker -- the browser will keep the current worker active while any tab to your browser is currently open.

You can navigate to a different host and back or close and reopen the tab(s). The self.skipWaiting function will also instruct the browser to let a new service worker take control immediately when used during install.

self.addEventListener('install', function(event) {
  event.waitUntil(self.skipWaiting());
});

Final word

I believe it's important for web developers to start thinking about ways to make our application more reliable in the eyes of consumers. While rendering an offline page isn't a game changer by itself, it is a low-risk way to begin experimenting with the Service Worker API which has the potential for many more advanced use cases and can help the web get closer to an even playing field with mobile apps.

Share this post on Twitter

Part of the Service Worker series. Published on May 9, 2016