DEV Community

Frank Pierce
Frank Pierce

Posted on • Updated on

Vanilla Service Workers in React

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

Web.dev’s Progressive Web App Tutorial

Mozilla’s Service Worker Documentation

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.

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);
      }
    )
  );
});
Enter fullscreen mode Exit fullscreen mode

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);
          }
        })
      )
    })
  )
})
Enter fullscreen mode Exit fullscreen mode

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))
  );
});
Enter fullscreen mode Exit fullscreen mode

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

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`)
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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))
  );
});
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

to

"build": "react-scripts build && npm run update-service-worker"
Enter fullscreen mode Exit fullscreen mode

and add an update-service-worker command:

"update-service-worker": "node scripts/update-service-worker.js"
Enter fullscreen mode Exit fullscreen mode

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 or email me at frank.pierceee@gmail.com

Top comments (0)