Ionic with Workbox Service Worker

Published: November 05, 2017  •  Updated: November 25, 2017  •  pwa, ionic

When you want to add a Service Worker to your web application, to make your site work offline or improve loading time on repeat-visits, you can either write it yourself from scratch or use a Service Worker library. Writing a Service Worker yourself is not the easiest task and most projects build their code with one of the libraries. It makes implementing the code easier and less error prone and you don't have to think about all the possible edge cases.

Popular libraries are sw-precache and sw-toolbox. At Google IO 2017 in May Google announced a new Service Worker library: Workbox. According to the documentation, Workbox is the successor to the aforementioned two libraries. After the first release in May, Google released version 2 in August and is working on version 3.

In this blog post I show you how you can integrate Workbox into the build process of an Ionic app and generate a Service Worker with this library.

Let's start with an empty application

ionic start myapp blank

When you create a new Ionic application with ionic start you get a sw-toolbox Service Worker for free. This service worker is by default disabled and you have to uncomment the code in the src/index.html file. Before we add Workbox we delete the sw-toolbox library and while we are at it also uninstall the ionic-native packages that we don't need for a web application. We also no longer need the resource folder that hosts assets for the Cordova build.

npm uninstall sw-toolbox
npm uninstall @ionic-native/core                     
npm uninstall @ionic-native/splash-screen            
npm uninstall @ionic-native/status-bar               
rm -r resources

Also remove the ionic native code from src/app/app.modules.ts and src/app/app.component.ts.

Next we install the Workbox library and command line interface.

npm install workbox-cli -D
npm install workbox-sw	

Open src/index.html and remove the script tag that imports cordova.js

<script src="cordova.js"></script>

and uncomment the code that installs the Service Worker. This is the code that ionic start generated for the sw-toolbox library, but the code works with the Workbox Service Worker too.

<script>
 if ('serviceWorker' in navigator) {
   navigator.serviceWorker.register('service-worker.js')
     .then(() => console.log('service worker installed'))
     .catch(err => console.error('Error', err));
 }
</script>

src/index.html

Next we have to create a custom copy configuration, that copies the Workbox library to the build folder (www). The Service Worker that we create depends on this library. Create a file copy.config.js and add this code:

module.exports = {
  copyWorkbox: {
    src: ['./node_modules/workbox-sw/build/importScripts/workbox-sw.prod.v2.1.2.js'],
    dest: '{{WWW}}'
  }
}

copy.config.js

Make sure that the version number matches the version of the installed Workbox library.
In my project I store this file in the root of the project, but you can store it in any directory you like. Remember to change the copy configuration each time you upgrade the Workbox library.

Now insert an ionic_copy configuration in the package.json file. This tells the Ionic build script to read our custom copy configuration and execute it together with the standard copy step.

"config": {
 "ionic_copy": "./copy.config.js"
},

package.json

Next we replace the existing sw-toolbox Service Worker code with the Workbox code. Open src/service-worker.js and overwrite it with this code

importScripts('workbox-sw.prod.v2.1.2.js');

const workboxSW = new self.WorkboxSW();
workboxSW.precache([]);

src/service-worker.js

This is the minimal code you need for a Workbox Service Worker. Import the library, create a new instance of the WorkboxSW object and call the precache method. Precache takes an array of resources and fetches and stores them into the cache. Note that the array is empty, we don't have to manually list all the resources we want to cache, the Workbox CLI takes care of this and injects the necessary code into this template.

Next we add the build step that does exactly this.

"postbuild": "workbox inject:manifest",

package.json

We name the task postbuild so npm will automatically run it after the build task (npm run build).

Before we start the build process we have to create a configuration file for the Workbox CLI. Create a new file workbox-cli-config.js in the root of the project and add this code.

module.exports = {
  "globDirectory": "www/",
  "globPatterns": [
    "assets/fonts/*.woff2",
    "build/**/*.css",
    "build/**/*.js",
    "index.html",
    "manifest.json"
  ],
  "swSrc": "src/service-worker.js",
  "swDest": "www/service-worker.js"
};

workbox-cli-config.js

globDirectory specifies the base directory where the resources are located we want to cache. globPatterns lists all the files we want to cache with the precache method. swSrc points to the Service Worker template we created earlier and swDest specifies the location where the Workbox CLI stores the modified file.

When the build process runs the command workbox inject:manifest, it first reads the configuration from workbox-cli-config.js, searches for all the resources in globDirectory that matches the pattern globPatterns, reads the src/service-worker.js file, adds an entry for each found resource to the precache([{...},{...}, ...]) code and stores the changed file in www/service-worker.js.

Instead of injection the resources into the service-worker.js template you can tell the Workbox CLI to create a Service Worker from scratch with the workbox generate:sw command. When you go this way you no longer need the src/service-worker.js file and you can remove the swSrc option from the workbox-cli-config.js file.
The disadvantage of this solution is that you can't add custom code to the Service Worker.

After all these preparations we can start the build with npm run build.

Workbox might complain about the file size of the vendor.js file. When we run a non optimized Ionic build the vendor.js has a size of over 4MB. The Workbox generator ignores by default files that are bigger than 2,097,152 Bytes. If you want to cache files that are bigger you have to add the following configuration option to the workbox-cli-config.js file

"maximumFileSizeToCacheInBytes": "5MB"

This setting would allow files up to 5MB to be stored in the cache.

After the build successfully finishes, check that the build process copied the file workbox-sw.prod.v2.1.2.js into the www folder and open the service-worker.js file with an text editor. Check the code that the inject:manifest added to the Service Worker. If the array is empty or files are missing check and adjust the globDirectory and globPatterns configuration options in workbox-cli-config.js.

The generated service worker file (www/service-worker.js) should look like this.

importScripts('workbox-sw.prod.v2.1.2.js');

const workboxSW = new WorkboxSW();
workboxSW.precache([
 {
   "url": "assets/fonts/ionicons.woff2",
   "revision": "311d81961c5880647fec7eaca1221b2a"
 },
 {
   "url": "assets/fonts/roboto-bold.woff2",
   "revision": "28d80f43ae4cc35f19e1f1a6ab670f25"
 },
....... 
 {
   "url": "build/vendor.js",
   "revision": "11885441e3852cb6dd187eb966f64b1f"
 },
 {
   "url": "index.html",
   "revision": "4dc650c5b995cbfac2bb2d54c6200244"
 }
]);

When the browser loads your application it first parses and executes the code he finds on the index.html, installs and executes the service-worker.js and the Service Worker fetches the listed resources and stores them in the cache.

What is the purpose of the revision field? To understand that you need to know that using fetch and the Cache API in the Service Worker does not bypass the http cache of the browser. When the Service Worker fetches a resource the browser first looks for the file in the http cache and returns it from there if it exists. This could be a problem when your web server serves assets with a cache control header and an expiration date far in the future. For this reason the Workbox library adds the revision hash to the URL when it fetches resources from the server. A GET request for the build/vendor.js would look like this

https://..../build/vendor.js?_workbox-precaching=11885441e3852cb6dd187eb966f64b1f

The additional _workbox-precaching request parameter is also called a cache buster because it forces the browser to load the newest version of the file from the server when the file is not already stored in the http cache with exactly this URL.

Each time the file content changes, the revision in the service-worker.js changes. This triggers a new installation of the Service Worker code and a request for the file with the new revision hash.

Make sure that you don't accidentally serve the file service-worker.js with a cache control header and a expiration date far in the future. The Browser would then load the file from the http cache and only request it from the server when the cache expires.

When you use ionic serve during development the browser loads the service-worker.js file from the src directory which does not cache anything (workboxSW.precache([]);), which I like because then nothing gets cached and I don't have to worry about the Service Worker interferes with the application.

But that's a bit of a problem when you want to test the generated Service Worker. Just opening the index.html file does not work because the browser does not install a Service Worker when you serve the site from the file:// protocol. You have to serve the site over the http protocol. You could copy the www folder to a web server or add a web server to the project. For this project I add the http-server npm package.

npm install http-server -D

and add an additional task

"open": "http-server www -o -a localhost -p 1234"

package.json

Now we can simply type npm run open and node starts a http server, opens the browser window and serves the content from the www folder. You can also combine the build step with the open task:
npm run build && npm run open
or start a production build with
npm run build --prod && npm run open

Note that the browsers only install Service Workers when they are served over a TLS connection. Exceptions are http connections from localhost or 127.0.0, they don't need to be encrypted.


More work

Unfortunately the previous setup has a flaw. When you open the application the first time with an empty cache, the browser parses the index.html page, requests all the referenced resources from the server and stores them in the http cache, installs the Service Worker and execute it. Now the Service Worker wants to cache all the resources and because he is using a cache busting URL for fetching the resources the request bypasses the http cache and all the files are sent twice over the network.

To solve that we need to make sure that the name of the resources the browser requests is the same on the index.html page and in the Service Worker. A solution is to add a hash to the file name. The hash is calculated from the content of the file. This way the name only changes when the content changes.

To do that we install two additional tools.

npm install map-replace -D  
npm install hashmark -D

hashmark is responsible for calculating the hash from the file content and renaming the file.
map-replace replaces the references to these files (for instance link and script tags in html pages).

Then we add these two tasks to our package.json

"hashcb": "hashmark -l 8 -r --cwd www/build \"*.{js,css}\" {name}.{hash}{ext} | map-replace -m \"<[^>]+>\" www/index.html",
"dist": "npm run clean && npm run build --prod && npm run hashcb && workbox inject:manifest"

package.json

The dist task is an aggregation of several other tasks, first it cleans the output directory, starts an Ionic production build, runs hashcb and injects the resources into the service worker file. Make sure that inject:manifest runs after the filename hashing process, because the service-worker.js needs to reference the hashed file names.

The hashcb tasks executes hashmark. The option -l 8 creates hash codes that are 8 bytes long, -r replaces the original file with the renamed file. -cwd www/build specifies the base directory where hashmark should look for files. *.{js,css} specifies the files it should process, in this case only files with a postfix js and css. The last parameter specifies the pattern for the new file name. With this configuration hashmark renames build/vendor.js to build/vendor.f2e3575e.js.

hashmark writes by default a JSON file to the standard output that looks like this

{"main.js":"main.cace105e.js","polyfills.js":"polyfills.a3d47f9d.js",
 "main.css":"main.62012289.css","vendor.js":"vendor.2a4333ee.js"}

The key is the original file name and the value contains the new hashed file name. map-replace is now able to read this file and replaces all the occurrences of the original file name with the new hashed filename.

For this application we only have to check the index.html file. When the hashcb task runs without any error you can check the index.html file. All the script and link tags should reference the hashed name.

<script src="build/vendor.f2e3575e.js"></script>

With this change in place we can now serve the assets with a cache control header and a long expiration duration. I often configure the web server to add a cache control header with an expiration date of one year in the future.

Cache-Control: public, max-age=31536000

Make sure that you the browser does not cache the index.html and service-worker.js file. The best way to do that is serving them with a no-cache header.

Cache-Control: no-cache 

There is one last configuration step we need to do. The Workbox CLI still generates the revision field.

workboxSW.precache([
  {
    "url": "build/main.39fbcc18.js",
    "revision": "ba76e4e1923eb5c0bbeca9028796862e"
  },

That means parsing the index.html would trigger a fetch for build/main.39fbbcc18.js and the Service Worker would request the same resource with the URL build/main.39fbcc18.js?_workbox-precaching=ba76e4e1923eb5c0bbeca9028796862e. Because the URLs are different, both requests would go to the server (when the http cache is empty) and transfer the same file twice over the network.

To suppress the revision field we need to add the dontCacheBustUrlsMatching option to the workbox-cli-config.js file and specify a regular expression. This regular expression checks if the name contains an 8 byte hash.

"dontCacheBustUrlsMatching": new RegExp('.+\.[a-f0-9]{8}\..+'),

workbox-cli-config.js

When the regular expression matches, the Workbox CLI does not add a revision field.

  {
    "url": "build/main.39fbcc18.js"
  },

With this setup in place, the browser first parses the index.html page, requests all the referenced resources and stores them in the http cache, then it installs and executes the Service Worker and requests the resources again. Because the Service Worker code requests the resources with the same name, the browser can simply return the content from the http cache and serve it to the Service Worker where the Workbox code stores them in the cache storage. When you observe the network requests in the Browser Developer Tools you should see both requests one served from the server and one from the http cache.

This setup is still not perfect. It still downloads the font files and the index.html page twice. To solve that we could get rid of the revision for all files in service-worker.js. You achieve this with the following configuration in workbox-cli-config.js

"dontCacheBustUrlsMatching": new RegExp('.+'),

workbox-cli-config.js

I think we don't have to worry about the webfonts files. My guess is that they never change. So we can still serve them with a cache control header and a long expiration duration. The index.html file is a bit different. When you serve a resource with a no cache header (Cache-Control: no-cache) the browser does not store it in the http cache. So in our case with or without the revision the browser downloads the file twice, during the initial download and again in the precache method of the Service Worker. This might not be a big issue because the file is very small.
Alternatively we could serve the file with a cache header and a very short max age.
For example 10 seconds: Cache-Control: public, max-age=10.
Enough time for the browser to load the file store it in the http cache run the Service Worker, fetch the content from the http cache and store in the cache storage.

Let me know what you think, send me a feedback if you find a better solution.

You find the complete source code for this project on GitHub:
https://github.com/ralscha/blog/tree/master/workbox