Service Workers in Progressive Web Apps

Service Workers in Progressive Web Apps

Service workers are the foundation of Progressive Web Applications. A service worker is kind of Web Worker, which means that this JavaScript is executed outside of the main browser thread. This allows us to (with user permission) send push notifications to the user's device, even when the app isn't open.

What does a service worker do?

A service worker enables a rich offline experience, and allows for periodic background syncs & push notifications.

A service worker's primary function is to cache resources and serve these cached resources inline with your caching strategy. e.g. Cache with Network Fallback, where the first port of call for any resource is the cache, if the resource isn't there then send a network request for the resource and serve that. You could even cache these resources as they aren't already in there!

Service worker lifecycle

The service worker lifecycle is straight-forward, there are 3 phases to starting up your service worker: Registration, Installation and Activation.

Registration

The Registration event is where the browser is told where to find your service worker and begins the installation process.

if ('serviceWorker' in navigator) {
    navigator.serviceWorker
        .register('/service-worker.js')
        .then((registration) => {
            console.log(`Service Worker Registered at scope: ${registration.scope}`);
        })
        .catch((error) => {
            console.log(`Service Worker failed registration: ${error}`);
        });
}

Note: Scope is important because the service worker can only access resources within it's scope!

This code starts by checking if the browser supports service workers and if it does, will register the service worker.

Installation

The Installation event is where new service workers are installed, this will happen the first time a user visits your web app or if there is a byte difference between the current and served service workers.

During the Installation event, we should cache the app shell or static assets, like so:

var cacheName = 'my-cache';

var myCache = [
  '/',
  '/offline.html',
  '/assets/css/style.css',
  '/assets/js/important.js',
  '/assets/images/big-image.png'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(cacheName).then((cache) => {
      return cache.addAll(myCache);
    }).catch((error) => {
      console.error(error);
    })
  );
  // you can reinstall the sw every time
  // by using: self.skipWaiting();
});

In our Install event we open the cache, of the name we defined on the very line, and then call cache.addAll() passing in our cache that we created. This is all done via a chain of promises, which is why we wrapped it all in event.waitUntil(). If a single item fails to cache from our defined assets, the promise will reject and we will get an error message.

If all the files cache successfully then the service worker is then finished installing and you can move onto the activation step. It's important that you try to keep your static cache assets succinct, so that there is a smaller chance of failure.

Activation

Once a service worker has successfully installed, it is activated and once it is activated it will control all pages that load within it's scope, and intercept all network requests.

You will have to either wait until the user refreshes the page or claim active pages once a service worker has been activated in a session, otherwise requests will continue as per normal and bypass the service worker.

It's common to use the Activation phase to delete outdated caches:

self.addEventListener('activate', (event) => {
  var cacheWhitelist = [cacheName];

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

As you can see in the above code, it's very similar to the Install event. However instead of caching some static assets, we're making sure the cache hasn't gone out of date. If the cache is now out of date then we will delete it.

Service workers maintain control as long as there are pages open that are dependent on it, that means new service workers won't load until all tabs for your web app relying on the old sw hae been closed (unless you skip waiting and claim the pages).

This is why we use this phase to clean up all our cached data.

Service worker events

Now that you know about the service worker lifecycle, it's time to learn about the functional events a service worker should handle. These events are triggered from other scripts: Fetch, Sync & Push.

Fetch

A fetch event is fired every time a resource is requested, a basic example of handling this in a service worker would check if the resource is in our cache and if so, serve that:

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

We're going to build on this example now to handle a multitude of issues your service worker could encounter.

Request Method

The first check we should run on a fetch event, is to check that it is GETing a resource and not trying to do anything else. We can do this by adding the following to our event listener:

self.addEventListener('fetch', (event) => {
    if (event.request.method === 'GET') {
        event.respondWith(
            caches.match(event.request)
        );
    }
});

This prevents our service worker trying to fetch other kinds of HTTP requests e.g. POST.

Protection against bad response types

An important aspect of your service worker is how robust it's error handling is, this means that we need to protect against two kinds of bad responses.

The first thing to check your response for is an opaque response. An opaque response is a blackbox response that we cannot examine in any meaningful way, this kind of response is served from origins without CORS enabled.

self.addEventListener('fetch', (event) => {
    if (event.request.method === 'GET') {
        event.respondWith(
            caches.match(event.request).then((response) => {
              // we must indirectly examine the response, otherwise
              // we would get an error when trying to read the statuscode.
              if (response.type === 'opaque') {
                return response;
              // response.ok means that the statuscode is successful. e.g. 200-299.
              } else if (!response.ok) {
                throw new Error(response.statusText);
              } else {
                // this is where we would introduce our caching strategy.
                return response;
              }
            }).catch((error) => {
              console.error(error);
              return false;
            });
        );
    }
});

In the above example we guard against both opaque responses and bad status codes. We must guard against bad status codes because the Fetch event will only fail if no response is received from the server e.g. the client or server is offline.

Caching with network fallback

There are a number of different caching strategies, the caching strategy I use for this blog (and also Wandr) is Cache with network fallback. This strategy means that the first port of call for any HTTP GET request, is to try and serve from the cache, failing this we send the network request.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    // try to match the requested resource against an item in the cache.
    caches.match(event.request).then((response) => {
      if (response) {
        // we have the resource in our cache, just serve!
        return response;
      }

      // if we haven't got the resource, fetch it via the network.
      return fetch(event.request).then((response) => {
        return caches.open(cacheName).then((cache) => {
          // we enter a clone of the response to the cache
          // because you can only serve the response once.
          cache.put(event.request.url, response.clone());
          return response;
        }).catch((error) => {
          // failed to open the cache, you can still return the response
          console.error(error);
          return response;
        });
      }).catch((error) => {
        // if the fetch event fails, we didn't receive a response from the server.
        // e.g. we are offline
        console.error(error);
        return caches.match('/offline.html');
      });
    })
  );
});

Full Fetch event

With all of the techniques we have looked at for your service worker's fetch event, you should have an event listener that looks like this:

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

        return fetch(event.request).then((response) => {
          if (response.type === 'opaque') {
            return response;
          } else if (!response.ok) {
            throw new Error(response.statusText);
          } else {
            return caches.open(cacheName).then((cache) => {
              cache.put(event.request.url, response.clone());
              return response;
            }).catch((error) => {
              console.error(error);
              return response;
            });
          }
        }).catch((error) => {
          console.error(error);
          return caches.match('/offline.html');
        });
      })
    );
  }
});

This is a robust and complete fetch event for any service worker implementing cache with network fallback, there is nothing more for me to add about the fetch event of a service worker. If you think there are any enhancement or modifications that should be made please leave a comment or tweet me.

Background Sync

The sync event is very important for the user experience because it allows us to defer actions in the service worker until the user has a stable connection. This is very useful for ensuring user actions are actually sent/completed.

To use the example of a chat application (along the lines of WhatsApp). You're on a train and trying to text your partner/housemates to say you'll be home late due to delays, but you don't have a stable internet connection. You don't realise until you're off the train that your message hasn't even sent, this is where background sync is useful.

Once you have a stable internet connection the background sync event can be fired, sending your message in the background. This means that the message can be sent as soon as possible and without any further need for interaction, we all remember the dark days of 'Error: Message could not be sent! Please try again'.

index.html

if ('serviceWorker' in navigator) {
  // Simplified registration of service worker
  navigator.serviceWorker.register('/service-worker.js');

  // One-off sync request
  navigator.serviceWorker.ready
    .then((swRegistration) => {
      return swRegistration.sync.register('myFirstSync');
    })
    .catch((error) => {
      throw new Error(error);
      return false;
    });
}

service-worker.js

self.addEventListener('sync', (event) => {
  if (event.tag === 'myFirstSync') {
    console.log('Hey, thisis my first sync... Be gentle!')
  }
});

The above example should be enough to get you started on syncing.

Push

The push event is a brilliant tool for sending notifications to your users, for instance if you were implementing your service worker for a blog you could push notifications to your users for a new post. Just like a toast notification on smartphones, your PWA doesn't need to be running to receive notifications, only the service worker needs to be running.

self.addEventListener('push', (event) => {
  if (event.data) {
    // you can parse data from the event in a number of ways:
    // e.g. .text(), .json(), .arrayBuffer(), .blob()
    console.log(`This push event has data:  ${event.data.text()}`);
  } else {
    console.log('This push event has no data.');
  }
});

The above example is enough for you to get to grips with the event, but in production we should wrap the notification in waitUntil(). This is because we don't have control over when the service worker will run, so we pass a promise into waitUntil() so that our service worker will be active at least until the promise is resolved. e.g.

self.addEventListener('push', (event) => {
  // self.registration.showNotification() is how we display push notifications.
  const promiseChain = self.registration.showNotification('Hello World!');

  event.waitUntil(promiseChain);
});

Leave a comment

Using this rather lengthy post, you should now be able to successfully implement a robust service worker that can handle fetch, push and sync events. If you've gotten lost along the way, can think of some enhancements or just need some help in sorting an issue please leave a comment or tweet me!