Make Site Content Available Offline using Service Workers

By Rob Gravelle

Make Site Content Available Offline using Service Workers

When the AppCache was first released a couple of years ago, it offered a means of providing (some of) a website's content offline. It was a great idea, but for several reasons, it made a less-than-stellar impression on web developers. Now there's a new player called the Service Worker. More versatile that the AppCache, it gives control over caching and how requests are handled to the developer. Hence, we should have everything we need to implement our own application cache. In today's article we'll learn how it's done!

The Service Worker Defined

A Service Worker is a script that your browser runs in the background, like a daemon, that executes processes that don't rely on a web page or user interaction. Right now, Service Workers can perform push notifications and background synchronization. In the future Service Workers will likely support other operations like periodic synching and geofencing. Of course, the feature that most interests us here today is their ability to intercept and handle network requests. That will allow us to programmatically manage a cache of stored resources.

Why is this Better than the Browser Cache?

Browser caches are quite transitory in nature, and for good reason. The more the browser goes to the cache instead of the server, the greater the chance that you're viewing out-of-date content. In fact, I'd be willing to bet that you've struggled with overzealous caching while developing a site. I came up with a dumb workaround of adding an incrementor to the URL, e.g. "?v=4"! Service workers, on the other hand, allow for more fine-grained control, so that you can make smarter decisions about what and when to cache.

The Demo: Selective Caching

We can get a feel for what Service Workers can do on the Web and Mobile Interest Group's (WebMob) GitHub project page. The specific demo that we will be looking at is the one for Selective Caching. It caches font resources by checking for responses with a Content-Type header that starts with "font/". The same principle may be applied to any category of content based on request or response headers. It is recommended that you use Chrome 40+ to view the demos, although one would assume that they would also work in a recent edition of Firefox (47+) or Opera (39+).

To launch the Selective Caching demo, click the link of the same name under the "Service Worker recipes" header.

Once you've got the page loaded, launch the Developer Tools and open the Console so that you'll be able to see the logging messages.

Exploring the index.html Page

In the HEAD section of the index.html document, there is a script that sets the protocol to HTTPS if it is anything else, such as HTTP or File. This is necessary because Service Workers only run over HTTPS.

if ((!location.port || location.port == "80") && location.protocol != 'https:') {
  location.protocol = 'https:';
}

Registering the Service Worker

At the bottom of the page, there is some code that tests for browser support and registers the service-worker.js script. The result of the registration is displayed in a message on the page.

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./service-worker.js', {scope: './'}).then(function() {
    // Registration was successful. Now, check to see whether the service worker is controlling the page.
    if (navigator.serviceWorker.controller) {
      // If .controller is set, then this page is being actively controlled by the service worker.
      document.querySelector('#status').textContent =
        'This funky font has been cached by the controlling service worker.';
    } else {
      // If .controller isn't set, then prompt the user to reload the page so that the service worker can take
      // control. Until that happens, the service worker's fetch handler won't be used.
      document.querySelector('#status').textContent =
        'Please reload this page to allow the service worker to handle network operations.';
    }
  }).catch(function(error) {
    // Something went wrong during registration. The service-worker.js file
    // might be unavailable or contain a syntax error.
    document.querySelector('#status').textContent = error;
  });
} else {
  // The current browser doesn't support service workers.
  var aElement = document.createElement('a');
  aElement.href = 'http://www.chromium.org/blink/serviceworker/service-worker-faq';
  aElement.textContent = 'Service workers are not supported in the current browser.';
  document.querySelector('#status').appendChild(aElement);
}

The service-worker.js Script

Service Workers offer a few events that you can listen for, although only two of them are required here: activate and fetch. Both are bound via the self pointer. Similar to this, self refers to the Global "ServiceWorkerGlobalScope" object, whereas this tends to be a bit more ambiguous, referencing the current object scope.

Inside the 'activate' Event Handler

One common task that will occur in the activate callback is cache management, i.e., the deleting of older, and thus out-of-date, caches. The following code iterates over all of the Service Worker caches and deletes any which aren't defined in the CURRENT_CACHES variable.

self.addEventListener('activate', function(event) {
  // Delete all caches that aren't named in CURRENT_CACHES.
  // While there is only one cache in this example, the same logic will handle the case where
  // there are multiple versioned caches.
  var expectedCacheNames = Object.keys(CURRENT_CACHES).map(function(key) {
    return CURRENT_CACHES[key];
  });

  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (expectedCacheNames.indexOf(cacheName) === -1) {
            // If this cache name isn't present in the array of "expected" cache names, then delete it.
            console.log('Deleting out of date cache:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Inside the fetch() Event Handler

The fetch event is fired every time the browser makes a request for a resource. We can call the event's respondWith() function to tell the Service Worker what to return to the browser. First,we call caches.open() with our desired cache name. It returns a Promise so that it may then be chained to the then() function. The cache's match() method also returns a Promise that resolves to the Response associated with the first matching request in the Cache object. If no match is found, the Promise returns the undefined constant. This tells us that there is no entry in the cache for the event.request, so we need to fetch() the resource from the server.

self.addEventListener('fetch', function(event) {
  console.log('Handling fetch event for', event.request.url);

  event.respondWith(
    caches.open(CURRENT_CACHES.font).then(function(cache) {
      return cache.match(event.request).then(function(response) {
        if (response) {
          // If there is an entry in the cache for event.request, then response will be defined
          // and we can just return it. Note that in this example, only font resources are cached.
          console.log(' Found response in cache:', response);

          return response;
        }

        console.log(' No response for %s found in cache. About to fetch ' +
          'from network...', event.request.url);

        //see next code snippet for the fetch() method
      }).catch(function(error) {
        console.error('  Error in fetch handler:', error);

        throw error;
      });
    })
  );
});

Caching the Resource

The fetch() method is a member of the Global "ServiceWorkerGlobalScope" object so it doesn't require the object qualifier. It consumes the request, so it should be copied before passing to the function. Once again, the then() function handles the response returned by fetch().

It's at this point that we can choose whether or not to cache the resource. The status check is there to make sure that we don't cache any invalid responses. We can deduce the resource type from the content-type header. If it starts with the word "font", we cache a copy of the response.

Finally, we return the original response to the browser.

return fetch(event.request.clone()).then(function(response) {
  console.log('  Response for %s from network is: %O',
    event.request.url, response);

  if (response.status < 400 &&
      response.headers.has('content-type') &&
      response.headers.get('content-type').match(/^font\//i)) {
    console.log('  Caching the response to', event.request.url);
    cache.put(event.request, response.clone());
  } else {
    console.log('  Not caching the response to', event.request.url);
  }

  return response;
});

Demo Output

The first time that you load the page, you should see a message in the console about caching the font resource:

Caching the response to https://fonts.gstatic.com/s/specialelite/v6/9-wW4zu3WNoD5Fjka35JmzxObtw73-qQgbr7Be51v5c.woff2

After that, reloading the page displays the following message, telling you that the Service Worker is serving the cached resource:

Found response in cache: Response {type: "cors", url: "https://fonts.gstatic.com/s/specialelite/v6/9-wW4zu3WNoD5Fjka35JmzxObtw73-qQgbr7Be51v5c.woff2", status: 200, ok: true, statusText: ""...}

Conclusion

As you can see, Service Workers make extensive use of Promises. These are quite a recent development themselves, so you may find it useful to refer to my Making Promises With jQuery Deferred article.



Rob Gravelle

Rob Gravelle resides in Ottawa, Canada, and is the founder of GravelleWebDesign.com. Rob has built web applications for numerous businesses and has recently developed his own jquery-tables library.

Rob's alter-ego, "Blackjacques", is an accomplished guitar player, that has released several CDs. His band, Ivory Knight, was rated as one of Canada's top hard rock and metal groups by Brave Words magazine (issue #92) and reached the #1 spot in the National Heavy Metal charts on ReverbNation.com.



Make a Comment

Loading Comments...

  • Web Development Newsletter Signup

    Invalid email
    You have successfuly registered to our newsletter.
  •  
  •  
  •  
Thanks for your registration, follow us on our social networks to keep up-to-date