My First Service Worker πŸ€–

March 25, 2019

Introduction

Service workers have been floating around my space of the web for the past few years. Along with terms like "Progressive Web Apps" and other futuristic technologies I wasn't sure what to make of them. To be honest, I was always put off by the name. It is so jargony and technical that I figured it must be another front-end fad that was better to let pass.

But of course, here I am several years later and they are still around. Service workers are being implemented all over the web; from sites trying to provide seamless offline experiences to mega blogs interested in bombarding you with desktop push notifications, service workers have become an integral part of the web experience.

So what is a service worker? In its simplest form, a service worker is a JavaScript file that has access to a page's requests and the subsequent responses. For some reason, I figured a service worker had to be way more complex than that. But no, not really. Service workers are just scripts that intercept network requests with a few other nifty APIs added on top.

What is the value in a script that intercepts page requests? One of the core motivations for service workers is to provide improved offline experiences. This is especially relevant for mobile devices (or my home WiFi network) where internet connection could be lost at any time.

On a mobile app, if you lose connection the app doesn't crash and burn. Instead, it will often display a message about the connection loss if relevant and allow you to continue using the app with whatever data it has downloaded. When connection returns, the app automatically reconnects and immediately performs the relevant network requests. This is a huge shift from web apps that require the user to refresh the page, reloading the entire app from scratch.

The allure of service workers is in their ability to give web apps the tools to perform similar functionality. This is especially impactful internationally where mobile data plans and storage may be limited and users do not want to download entire apps to their devices.

Writing a Service Worker

To start writing a service worker I recommend understanding event-based programming and Promises in JavaScript.

Service workers respond to events - the FetchEvent, InstallEvent, NotificationEvent, and SyncEvent to name a few. I would definitely recommend understanding JavaScript event-based programming and callbacks before learning to write a service worker.

While Promise logic is not always easy to follow, understanding the way they work is required in order to write a service worker. Service worker APIs rely heavily on Promises because they are intercepting requests, so it is important the code is non-blocking and asynchronous.

All that is left to begin writing a service worker is learning the related APIs and how they fit together. This requires a bit of memorization off the bat, but luckily there is great documentation available for reference. (love you MDN ❀️)

The service worker I wrote is relatively minimal. Its purpose is to allow a site to be loaded from CacheStorage when the user is offline.

Service Worker Lifecycle

Service worker files are loaded by the browser differently than normal HTML, CSS and JS files. Instead of being fetched and parsed on page load, service workers go through an install process, or "activation lifecycle". The service worker begins to work through its lifecycle when it is "registered" by a script on a page. After registration, the service work is cached by the browser until it is replaced by a new service worker.

The service worker lifecycle looks like this:

  1. download
  2. install
  3. activate

The install and activate events will both be fired when a new service worker is registered for use. The difference between the two is that the install event can execute while another service worker is still active.

There can only be one activated version of a service worker at a time. An old service worker that is ready to be replaced will remain activated as long as one page in the user's browser is still using it.

So how should we use each of these lifecycle events? The install event is useful for setting up any initial data the service worker will need. An example use of the install event could be to fetch and cache an offline.html file so that it is ready if the connection is lost. Because the activate event will only run when no other service workers are activated, we can safely use this method to clean up any cached data left over by a previous service worker.

A webpage can register a service worker by calling the navigator.serviceWorker.register() method.

// script.js

// first, check if the serviceWorker API is supported!
if ('serviceWorker' in navigator) {
  // navigator.serviceWorker.register(pathToServiceWorker, options)
  navigator.serviceWorker.register('sw.js', { scope: '/' })
}

Responding to each looks a little something like this:

// sw.js

self.addEventListener('install', event => {
  // handle event
})

self.addEventListener('activate', event => {
  // handle event
})

Handling Requests

After a service worker has been installed and activated it can begin processing web requests. In order to do so, it will need to listen for the fetch event. Handling it fetch event is where the meat of this service worker will live.

// sw.js

self.addEventListener('fetch', event => {
  // handle event
})

An empty event handler isn't too fun, time to add something to it! The below code will log all of the requests for a given web page. I've linked a Glitch project that can be used as a starting point if you want to follow along.

Demo

// sw.js

self.addEventListener('fetch', event => {
  // log request url
  console.log('logging!', event.request.url)
})

Service workers also have the ability to respond to the requests. Below is a service worker that accomplishes absolutely nothing. πŸ™ƒ

// sw.js

self.addEventListener('fetch', event => {
  event.respondWith(fetch(event.request))
})

In this case, the event.respondWith() method is used to respond to the request. The method accepts a Promise to dictate how the service worker should respond to the request. The above example will use the fetch API to make the same request that the browser would have made if the service worker were not present.

Great! Now that the service worker responds to requests, things can start to get a little more interesting.

Caching Requests

If a webpage has some data or imagery that is needed by many pages it may be a good idea to cache these items so they are not downloaded in the future. Caching the HTML page and its assets will allow a page to be loaded offline.

Service workers rely on the CacheStorage API for caching requests. The Chrome developer tools even have a special area for viewing the items in "Cache Store".

Updated cache
View of Cache Storage in Dev Tools

// sw.js

self.addEventListener('fetch', event => {
  caches.open('my-cache').then(cache => cache.add(event.request))
})

Note! cache.add(request) is shortcut for cache.put(request, response).

Woohoo! Now the service worker is storing responses to requests in the cache!

So the data is being added to the cache, but never used afterward. In order to do that the service worker will have to call the event.respondWith() from the previous section. Time to update the service work with the following behavior:

  1. If a request exists in the cache, respond with the cached response
  2. If the request is not in the cache, fetch and cache the response

Demo

// sw.js

function findInCache(request) {
  return caches.match(request).then(response => {
    if (!response) {
      throw new Error(`${request.url} not in cache`)
    }
    return response
  })
}

function addToCache(request, response) {
  if (response.ok) {
    const responseCopy = response.clone()
    caches.open('my-cache').then(cache => cache.put(request, responseCopy))
  }
  return response
}

self.addEventListener('fetch', event => {
  const { request } = event
  event.respondWith(
    findInCache(request)
      .catch(error => fetch(request))
      .then(response => addToCache(request, response))
      .catch(error => console.log(error))
  )
})

Note! In addToCache() the responseCopy is created because a response can only be read once. If we read the value into the cache, then it will not be available to pass back to event.respondWith().

Remember how I said a big part of writing service workers is managing Promises? More to come.

Anyways, now the service worker is actually doing something. Here's where the code stands:

  • Any uncached requests will be fetched and cached.
  • Requests that have already been cached are pulled from the cache without ever making the request.

This means that after loading the page, we should be able to turn off our WiFi, refresh, and still see the webpage. Without the service worker, we would have seen the "This page is offline" message from the browser.

Finishing Touches

This is great, but caching everything on the first load will prevent the site from updating when changes are published. Because the names of HTML files rarely change, we don't want to read these files from the cache unless absolutely necessary. Other files, like scripts and images, are less likely to have file contents change under the same file name so it makes sense to read them from the cache. Also, they tend to have larger file sizes so caching them is more valuable.

The new logic flow should be:

  • If online
    • Fetch the requested page
    • Update the cache with the response
  • If offline
    • Return the last cached page
    • If the cache is empty then display an offline message

In order to detect if a request is for a page, we can check the request mode on the FetchEvent.

Final Demo

// sw.js

// ... addToCache and findInCache methods

function offlineResponse() {
  return new Response(
    '<h1 styling="padding: 20px">Uh, oh! Looks like you are offline :(</h1>',
    {
      headers: {
        'Content-Type': 'text/html',
      },
    }
  )
}

self.addEventListener('fetch', event => {
  const { request } = event

  // if requesting a page load
  if (request.mode === 'navigate') {
    event.respondWith(
      fetch(request)
        // online -> fetch a fresh copy and update cache
        .then(response => addToCache(request, response))

        // offline -> find in cache
        .catch(error => findInCache(request))

        // not in cache -> display offline message
        .catch(error => offlineResponse())
    )
  } else {
    event.respondWith(
      findInCache(request)
        .catch(error => fetch(request))
        .then(response => addToCache(request, response))
        .catch(error => console.log(error))
    )
  }
})

With this change, pages should always fetch the most up-to-date version when online and all other assets will be loaded from the cache when available.

You can play around with this in the Demo.

First, make sure that the Cache Storage is empty. Then, load the home page and view the contents of the cache. You should see that /, /script.js and /style.css have been added to the cache. Next, click the "Offline" checkbox under the "Service Workers" menu item.

Next, click the link on the page for 'Page 2'. Refresh the page and the offline message will display because Page 2 has not been added into the cache. Lastly, return to the home page and refresh. It loads even though the site is offline. Wonderful!

Conclusion

This is really just a taste of what service workers can do. In this exercise, we were able to cache a site for offline browsing with a relatively small amount of code. Now, this is absolutely not a Production Readyβ„’ service worker, but I think it provides a solid starting point to continue learning about service workers.

Besides the caching functionality used above, service workers have a few other notable features. Everyone's favorite push notifications allow web applications to send notifications to a device, the Channel Messaging API allows a service worker to send messages to the JS files on the site/app that it is in context with, and the futuristic Background Sync API (not standardized) will allow the service worker to queue up actions to perform once a user's internet connection is returned. All of these have the ability to improve user experience on the web.

By working through this exercise I learned that service workers are relatively simple to write, but perhaps the strategy around writing one well is the tricky part. I'm looking forward to continuing to experiment and learn about service workers.

If you have a question or noticed a problem with any part of this post, please reach out through any social network a let me know.

Happy coding πŸ€“

Further Reading:

Copyright 2018 - 2021