Vanilla Service Workers in React
Tags: pwa, webdev, javascript, react
Published: 2023-01-18 12:07:07

_The following article explains how to make your React web application offline compatible and PWA compliant through Service Workers without using the countless libraries that are often prescribed, specifically how to cache files with dynamic names. If you’re in a rush, the secret is in Step 7._

I will presume that you have an understanding of what a Progressive Web Application (PWA) is and how Service Workers are used in PWAs. If you’re not familiar with these concepts (or need a refresher), I highly recommend the following resources:

[Jake Archibald’s riveting talk on Progressive Web Apps and Service Workers](https://www.youtube.com/watch?v=cmGr0RszHc8)

[Web.dev’s Progressive Web App Tutorial](https://web.dev/progressive-web-apps/)

[Mozilla’s Service Worker Documentation](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)

If, like me, you marvelled at the features that Progressive Web Applications bestow then you likely immediately tried to integrate them into your projects.

The project that I decided to refactor into a PWA was a lightweight React web application which converts concert setlists into Spotify playlists. My main goal was to forge my application into a PWA which would display while the user is offline due to the service worker serving cached files.

Three attributes are required to qualify as a Progressive Web Application:

- HTTPS — All PWAs must run on HTTPS as many core PWA technologies, such as service workers, require HTTPS.
- manifest.json — Manifest.json files are required as they provide vital information to the browser regarding how it should behave when installed on the user’s desktop or mobile device. They contain data such as icon names, background colours, display preferences…
- Service Worker — Service Workers are necessary for many of the prominent PWA features such as Offline First, Background Sync and Notifications.

Hosting your web application on HTTPS and writing a manifest.json file are both easy and well-documented processes.

And, while more technical, creating a Service Worker which caches files and serves them from the cache while the user is offline (and even online if the files are available) is reasonably straight forward too. Or, so I thought until I ran into cache-busting hashes.

Our Service Worker will have 3 purposes:

- Cache the app’s static assets (HTML, CSS, JS, Fonts…) in the browser’s cache.
- Delete old caches when a new version becomes available.
- Intercept fetch requests and serve assets from the cache when available. Otherwise, fetch as normal from the network.


## Step 1: Take the Red Pill
If you Google ‘How to make React App into offline-first PWA’, you’ll be bombarded with countless helpful articles and tutorials. Most, if not all, of these will tell you to install any number of libraries and external packages all of which require you to refactor much of your project to complete the task.

Now, I love libraries. They’re open-source, optimised, reliable code written by experts and provided to you for free.

However, the primary drive behind my development at the time was to learn. Sure I could import 5 npm packages, write a few lines of code and have a fully functioning PWA. But where’s the fun (or learning) in that?

Instead, let’s write our own Service Worker for React.

## Step 2: Registering and Creating our Service Worker
React CRA provides your project with a file which creates and registers a service worker. If your project doesn't have it you can find it [here](https://github.com/cra-template/pwa/blob/main/packages/cra-template-pwa/template/src/serviceWorkerRegistration.js).

You can write this yourself, as I have previously, but React’s template is excellent and allows us to focus on the fun stuff.

In your `public` folder, create a file called `service-worker.js`. This will be where all our service worker code goes.

## Step 3: Caching Assets
For our application to function offline, we must cache all crucial static files. To do this we’ll create a cache name, an array of files to cache and an Event Listener which caches the files. My `service-worker.js` begins as below:

```
const CACHE_NAME = "spotlist-cache-v1";

const STATIC_ASSETS = [
  "/index.html",
  "/manifest.json",
  "/static/js/main.js",
  "/static/css/main.css",
  "/static/media/GTUltra-Regular.woff"
]

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        cache.addAll(STATIC_ASSETS);
      }
    )
  );
});
```
We define a cache name with the `CACHE_NAME` variable. It’s preferable to include a version number here for reasons explained later.

We then create a `STATIC_ASSETS` array with the path to each of the files we want to add to our cache. Note that the above variable names are up to you.

Finally, we add an event listener to _self_ which in this context is the service worker itself. We listen for the install event which is called when the service worker is finished installing. When the listener is triggered we do the following:

- Find or create a cache with the name in `CACHE_NAME`
- Add all of the files in `STATIC_ASSETS` to said cache.
- Simple right? Now, whenever a user opens our application we’ll cache all of the files necessary for it to run offline.

## Step 4: Removing Old Caches
I mentioned earlier that your cache name should include a version number (such as _v1_). If we a user visited our application, they’d cache our files and be stuck using them whether we changed the site’s content or not. That’s not ideal.

The solution is to purge old caches and create new ones whenever the files’ content changes. To do this we’ll change the `CACHE_NAME` version number each time we update the app’s content and create some logic which will delete caches that don't match the current version name.

```
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cache => {
          if (cache !== CACHE_NAME) {
            caches.delete(cache);
          }
        })
      )
    })
  )
})
```
Akin to our previous logic, we add an event listener to our service worker and listen for the activate event. It is fired on the active service worker each time the page reloads. This is a perfect time to check if a new cache is available.

We call `.keys()` on cache to access an array of each cache name stored in the browser. We then map over each name and check if it matches the current `CACHE_NAME`. If it doesn't, we delete that cache.

Again, simple. But necessary.

## Step 5: Serving from the Cache
Successfully storing our applications files in our users' cache is an achievement. Although a futile one if we cannot access them.

```
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => response || fetch(event.request))
  );
});
```
We intercept all network requests by listening for the _fetch_ event. We then prevent the default _fetch_ behaviour with `event.respondWith()` inside which we:

- Check if the requested URL is in the cache — if it is, respond with it
- If it isn’t we fetch from the network as usual

If you run a Lighthouse report on your application in Chrome Developer Tools you should find that it is now a verified Progressive Web App making it installable on phones, desktops and other devices directly from the browser:

![Lighthouse Audit](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iwwttb8xhgh2yjwwlkdp.png)

## Step 6: The Catch — Dynamic Filenames
That seemed far too easy. Why would anyone need external libraries? Remember when I said writing Service Workers seemed easy until I ran into cache-busting hashes? Well, that’s one of the main reasons developers will install a handful of libraries.

React, alongside almost all other asset pipelines, implements cache busting allowing users to receive the most recently updated files without having to perform a hard refresh or clear their browser cache.

React does this by adding hashes to the filenames it outputs. The files:

- main.js
- main.css
- GTUltra-Regular.woff

Are renamed as below during the React build process:

- main.7000985f.js
- main.bb03239a.css
- GTUltra-Regular.41205ca9d5907eb1575a.woff

While this is essential for cache busting, it throws a randomly generated wrench into our plans. We hardcoded the names of our files to be cached into `service-worker.js`

How can we account for randomly generated filenames?

I could abandon my mission to write my own React-compatible Service Worker and flee to the comfort of expertly crafted libraries such as Google’s Workbox.

Or I could find my own solution.

## Step 7: The Solution
I turned to Google for help. I figured many others before must have run into the same issue. Predictably, I found a multitude of Stackoverflow questions, Reddit posts and GitHub issues on the subject. But none had anything resembling a concrete answer (other than to disable the cache busting hashes — something I hoped to avoid).

> If you’re overwhelmed by the size of a problem, break it down into smaller pieces.

And so I did. Here are the smaller pieces I came up with:

- React’s build process adds an unknowable hash to certain filenames.
- Our Service Worker needs the name, hashes included, of each filename.
- The Service Worker is written before the build process occurs.

Likewise, here are some facts I knew or found through research which may help us:

- React’s build process creates `asset-manifest.json`. A file containing the paths of all generated assets, hashes included.
- We can run scripts directly following React’s build process by appending them to the build command in `package.json`

So here’s the theory: we can write a script, which runs directly after the build process, that parses `asset-manifest.json` for the hashed filenames and modifies `service-worker.js` to include them in our filenames array.

Create a folder called `scripts` in your project root directory and inside it a file with the name of your choice; I called mine `update-service-worker.js`. Its content is the following:

```
const fs = require('fs');
const assetManifest = require('../build/asset-manifest.json');

const urls = Object.values(assetManifest.files).filter(name => !name.includes('.map'))

fs.readFile('build/service-worker.js', (error, content) => {

  const newContent = data.replace("%HASHURLS%", JSON.stringify(urls));

  fs.writeFile('build/service-worker.js', newContent, (error) => {
    error ? console.log(`Error: ${error}`) : console.log(`Success`)
  });
});
```

We import `fs`, Node’s filesystem module. We also import `asset-manifest.js`.

We use the `Object.values()` method on the asset manifests file object (where the generated file names are found) to retrieve an array of hashed filenames. We then filter through them to remove the source map files. You could leave these in and cache them if you wanted, but there’s little reason to.

Perfect, we’ve got an array of the hashed filenames. You can `console.log(urls)` here to verify this if you’d like.

Now, we simply need to add the hashed filenames to `service-worker.js`.

We read the contents of `service-worker.js`, then search the contents for `%HASHURLS%`, replace it with our hashed filenames and finally write this updated content back to `service-worker.js`.

Sounds like magic, right?

Well, that's simply because I haven’t told you about the changes we must make to `service-worker.js` to enable the above. Add the following just below where you have defined your `STATIC_ASSETS` array.

```
let CACHE_ASSETS = STATIC_ASSETS.concat(JSON.parse('%HASHURLS%'));

CACHE_ASSETS = new Set(CACHE_ASSETS);

CACHE_ASSETS = Array.from(CACHE_ASSETS);
```

This code is almost too simple to explain:

- We concatenate our `STATIC_ASSETS` array with `%HASHURLS%` which represents, and will be replaced by, our array of hashed filenames.
- We transform the resulting array into a set to remove duplicates.
- And back into an array.

You’ll also need to change the `STATIC_ASSETS` variable name in the install listener to our new array: `CACHE_ASSETS`.

The final `service-worker.js` file should resemble the following:

```
const CACHE_NAME = "spotlist-cache-v1";

const STATIC_ASSETS = [
  "/index.html",
  "/manifest.json"
]

let CACHE_ASSETS = STATIC_ASSETS.concat(JSON.parse('%HASHURLS%'));

CACHE_ASSETS = new Set(CACHE_ASSETS);

CACHE_ASSETS = Array.from(CACHE_ASSETS);

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        cache.addAll(CACHE_ASSETS);
      }
    )
  );
});

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cache => {
          if (cache !== CACHE_NAME) {
            caches.delete(cache);
          }
        })
      )
    })
  )
})

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

The final step is to run the `update-service-worker.js` script.

Change the `build` command of your `package.json` file from:

```
"build": "react-scripts build"
```
to

```
"build": "react-scripts build && npm run update-service-worker"
```
and add an `update-service-worker` command:

```
"update-service-worker": "node scripts/update-service-worker.js"
```

**Congratulations**, you now have an offline compatible PWA without using a single external library.

## Conclusion:
You don’t need external libraries to take advantage of the exceptional modern features of Service Workers, even when dealing with cache busting hashes in your dynamically generated filenames.

This is my novel approach to a problem I encountered without any obvious or documented solutions. If you have any suggestions on improvements or alternatives, I’d love to hear them.

You can find me at [frankpierce.me](https://www.frankpierce.me/) or email me at frank.pierceee@gmail.com