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);
}
)
);
});
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:
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 or email me at frank.pierceee@gmail.com
Top comments (0)