We asked web builders that we admire the same question...

What is one thing people can do to make their website better?

Thanks to our major sponsors in 2021. They are a big part of making this site possible.

Add a Service Worker to Your Site

One of the best things you can do for your website in 2022 is add a service worker, if you don’t have one in place already. Service workers give your website super powers. Today, I want to show you some of the amazing things that they can do, and give you a paint-by-numbers boilerplate that you can use to start using them on your site right away.

What are service workers?

A service worker is a special type of JavaScript file that acts like middleware for your site. Any request that comes from the site, and any response it gets back, first goes through the service worker file. Service workers also have access to a special cache where they can save responses and assets locally.

Together, these features allow you to…

  • Serve frequently accessed assets from your local cache instead of the network, reducing data usage and improving performance.
  • Provide access to critical information (or even your entire site or app) when the visitor goes offline.
  • Prefetch important assets and API responses so they’re ready when the user needs them.
  • Provide fallback assets in response to HTTP errors.

In short, service workers allow you to build faster and more resilient web experiences.

Unlike regular JavaScript files, service workers do not have access to the DOM. They also run on their own thread, and as a result, don’t block other JavaScript from running. Service workers are designed to be fully asynchronous.

Security

Because service workers intercept every request and response for your site or app, they have some important security limitations.

Service workers follow a same-origin policy.

You can’t run your service worker from a CDN or third party. It has to be hosted at the same domain as where it will be run.

Service workers only work on sites with an installed SSL certificate.

Many web hosts provide SSL certificates at no cost or for a small fee. If you’re comfortable with the command line, you can also install one for free using Let’s Encrypt.

There is an exception to the SSL certificate requirement for localhost testing, but you can’t run your service worker from the file:// protocol. You need to have a local server running.

Adding a service worker to your site or web app

To use a service worker, the first thing we need to do is register it with the browser. You can register a service worker using the navigator.serviceWorker.register() method. Pass in the path to the service worker file as an argument.

navigator.serviceWorker.register('sw.js');

You can run this in an external JavaScript file, but prefer to run it directly in a script element inline in my HTML so that it runs as soon as possible.

Unlike other types of JavaScript files, service workers only work for the directory in which they exist (and any of its sub-directories). A service worker file located at /js/sw.js would only work for files in the /js directory. As a result, you should place your service worker file inside the root directory of your site.

While service workers have fantastic browser support, it’s a good idea to make sure the browser supports them before running your registration script.

if (navigator && navigator.serviceWorker) {
  navigator.serviceWorker.register('sw.js');
}

After the service worker installs, the browser can activate it. Typically, this only happens when…

  • there is no service worker currently active, or
  • the user refreshes the page.

The service worker won’t run or intercept requests until it’s activated.

Listening for requests in a service worker

Once the service worker is active, it can start intercepting requests and running other tasks. We can listen for requests with self.addEventListener() and the fetch event.

// Listen for request events
self.addEventListener('fetch', function (event) {
  // Do stuff...
});

Inside the event listener, the event.request property is the request object itself. For ease, we can save it to the request variable.

Certain versions of the Chromium browser have a bug that throws an error if the page is opened in a new tab. Fortunately, there’s a simple fix from Paul Irish that I include in all of my service workers, just in case:

// Listen for request events
self.addEventListener('fetch', function (event) {

  // Get the request
  let request = event.request;

  // Bug fix
  // https://stackoverflow.com/a/49719964
  if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') return;

});

Once your service worker is active, every single request is sent through it, and will be intercepted with the fetch event.

Service worker strategies

Once your service worker is installed and activated, you can intercept requests and responses, and handle them in various ways. There are two primary strategies you can use in your service worker:

  1. Network-first. With a network-first approach, you pass along requests to the network. If the request isn’t found, or there’s no network connectivity, you then look for the request in the service worker cache.
  2. Offline-first. With an offline-first approach, you check for a requested asset in the service worker cache first. If it’s not found, you send the request to the network.

Network-first and offline-first approaches work in tandem. You will likely mix-and-match approaches depending on the type of asset being requested.

Offline-first is great for large assets that don’t change very often: CSS, JavaScript, images, and fonts. Network-first is a better fit for frequently updated assets like HTML and API requests.

Strategies for caching assets

How do you get assets into your browser’s cache? You’ll typically use two different approaches, depending on the types of assets.

  1. Pre-cache on install. Every site and web app has a set of core assets that are used on almost every page: CSS, JavaScript, a logo, favicon, and fonts. You can pre-cache these during the install event, and serve them using an offline-first approach whenever they’re requested.
  2. Cache as you browser. Your site or app likely has assets that won’t be accessed on every visit or by every visitor; things like blog posts and images that go with articles. For these assets, you may want to cache them in real-time as the visitor accesses them.

You can then serve those cached assets, either by default or as a fallback, depending on your approach.

Implementing network-first and offline-first strategies in your service worker

Inside a fetch event in your service worker, the request.headers.get('Accept') method returns the MIME type for the content. We can use that to determine what type of file the request is for. MDN has a list of common files and their MIME types. For example, HTML files have a MIME type of text/html.

We can pass the type of file we’re looking for into the String.includes() method as an argument, and use if statements to respond in different ways based on the file type.

// Listen for request events
self.addEventListener('fetch', function (event) {

  // Get the request
  let request = event.request;

  // Bug fix
  // https://stackoverflow.com/a/49719964
  if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') return;

  // HTML files
  // Network-first
  if (request.headers.get('Accept').includes('text/html')) {
    // Handle HTML files...
    return;
  }

  // CSS & JavaScript
  // Offline-first
  if (request.headers.get('Accept').includes('text/css') || request.headers.get('Accept').includes('text/javascript')) {
    // Handle CSS and JavaScript files...
    return;
  }

  // Images
  // Offline-first
  if (request.headers.get('Accept').includes('image')) {
    // Handle images...
  }

});

Network-first

Inside each if statement, we use the event.respondWith() method to modify the response that’s sent back to the browser.

For assets that use a network-first approach, we use the fetch() method, passing in the request, to pass through the request for the HTML file. If it returns successfully, we’ll return the response in our callback function. This is the same behavior as not having a service worker at all.

If there’s an error, we can use Promise.catch() to modify the response instead of showing the default browser error message. We can use the caches.match() method to look for that page, and return it instead of the network response.

// Send the request to the network first
// If it's not found, look in the cache
event.respondWith(
  fetch(request).then(function (response) {
    return response;
  }).catch(function (error) {
    return caches.match(request).then(function (response) {
      return response;
    });
  })
);

Offline-first

For assets that use an offline-first approach, we’ll first check inside the browser cache using the caches.match() method. If a match is found, we’ll return it. Otherwise, we’ll use the fetch() method to pass the request along to the network.

// Check the cache first
// If it's not found, send the request to the network
event.respondWith(
  caches.match(request).then(function (response) {
    return response || fetch(request).then(function (response) {
      return response;
    });
  })
);

Pre-caching core assets

Inside an install event listener in the service worker, we can use the caches.open() method to open a service worker cache. We pass in the name we want to use for the cache, app, as an argument.

The cache is scoped and restricted to your domain. Other sites can’t access it, and if they have a cache with the same name the contents are kept entirely separate.

The caches.open() method returns a Promise. If a cache already exists with this name, the Promise will resolve with it. If not, it will create the cache first, then resolve.

// Listen for the install event
self.addEventListener('install', function (event) {
  event.waitUntil(caches.open('app'));
});

Next, we can chain a then() method to our caches.open() method with a callback function.

In order to add files to the cache, we need to request them, which we can do with the new Request() constructor. We can use the cache.add() method to add the file to the service worker cache. Then, we return the cache object.

We want the install event to wait until we’ve cached our file before completing, so let’s wrap our code in the event.waitUntil() method:

// Listen for the install event
self.addEventListener('install', function (event) {

  // Cache the offline.html page
  event.waitUntil(caches.open('app').then(function (cache) {
    cache.add(new Request('offline.html'));
    return cache;
  }));

});

I find it helpful to create an array with the paths to all of my core files. Then, inside the install event listener, after I open my cache, I can loop through each item and add it.

let coreAssets = [
  '/css/main.css',
  '/js/main.js',
  '/img/logo.svg',
  '/img/favicon.ico'
];

// On install, cache some stuff
self.addEventListener('install', function (event) {

  // Cache core assets
  event.waitUntil(caches.open('app').then(function (cache) {
    for (let asset of coreAssets) {
      cache.add(new Request(asset));
    }
    return cache;
  }));

});

Cache as you browse

Your site or app likely has assets that won’t be accessed on every visit or by every visitor; things like blog posts and images that go with articles. For these assets, you may want to cache them in real-time as the visitor accesses them. On subsequent visits, you can load them directly from cache (with an offline-first approach) or serve them as a fallback if the network fails (using a network-first approach).

When a fetch() method returns a successful response, we can use the Response.clone() method to create a copy of it.

Next, we can use the caches.open() method to open our cache. Then, we’ll use the cache.put() method to save the copied response to the cache, passing in the request and copy of the response as arguments. Because this is an asynchronous function, we’ll wrap our code in the event.waitUntil() method. This prevents the event from ending before we’ve saved our copy to cache. Once the copy is saved, we can return the response as normal.

/explanation We use cache.put() instead of cache.add() because we already have a response. Using cache.add() would make another network call.

// HTML files
// Network-first
if (request.headers.get('Accept').includes('text/html')) {
  event.respondWith(
    fetch(request).then(function (response) {

      // Create a copy of the response and save it to the cache
      let copy = response.clone();
      event.waitUntil(caches.open('app').then(function (cache) {
        return cache.put(request, copy);
      }));

      // Return the response
      return response;

  }).catch(function (error) {
      return caches.match(request).then(function (response) {
        return response;
      });
    })
  );
}

Putting it all together

I’ve put together a copy-paste boilerplate for you on GitHub. Add your core assets to the coreAssets array, and register it on your site to get started.

If you do nothing else, this will be a huge boost to your site in 2022.

But there’s so much more you can do with service workers. There are advanced caching strategies for APIs. You can provide an offline page with critical information if a visitor loses their network connection. You can clean up bloated caches as the user browses.

Jeremy Keith’s book, Going Offline, is a great primer on service workers. If you want to take things to the next level and dig into progressive web apps, Jason Grigsby’s book dives into the various strategies you can use.

And for a pragmatic deep dive you can complete in about an hour, I also have a course and ebook on service workers with lots of code examples and a project you can work on.