Home | Send Feedback

Angular project with Workbox service worker

Published: 22. June 2018  •  Updated: 30. March 2020  •  pwa, javascript

Adding a service worker to an Angular app is quite easy. Issue the command ng add @angular/pwa, and it adds a fully functionally service worker to your project that caches all your assets. As convenient that this solution is, it has the drawback that you have to use the code that the library provides, and there is no way to change it. Depending on your use case, this might not be a problem, and the Angular service worker fits your needs.

There is a way to add additional custom code to the Angular service worker. If you want to learn more visit my blog post that shows you how to add code for background synchronization to an Angular service worker.

Read more about the Angular service worker here:
https://angular.io/guide/service-worker-intro


When the Angular service worker does not cover your use case, or you want to be in full control of the service worker, you have to write your own code. You can do this either from scratch or with the help of a library. Writing a service worker from scratch is not the easiest thing, and you have to be vigilant that your code covers all cases correctly. To simplify development, we choose the latter approach and write the service worker with the help of the Workbox library from Google. The library provides a high-level API that makes it very convenient to write service worker code.

This tutorial does not cover every aspect of the Workbox library. We only see a small aspect of the library that covers resource caching. We use that part of Workbox to cache all our resources so that the Angular application can run even when it does not have a network connection.

Manifest

Every PWA needs a manifest file. This is a JSON file that tells the browser about your Progressive Web App (PWA), and configures things like the name and paths to the icons.

For this application, I created the manifest file with this online generator: https://app-manifest.firebaseapp.com/
manifest generator

Enter the requested information and upload an image with a size of 512x512 pixels. The service creates the JSON file and icons in different sizes.

Copy manifest.json into the src folder. Copy the icons anywhere in the assets folder. I copied them into the src/assets/icons directory. If necessary, change the path to the icons in the manifest file.


We have to tell Angular about the new file. Add "src/manifest.json" to the assets array. Angular CLI ignores the file if it's not listed there.

            "assets": [
              "src/favicon.ico",
              "src/assets",
              "src/manifest.json",
              "src/service-worker.js"
            ],

angular.json


And lastly, we add a link to the manifest file in the index.html file

  <link href="manifest.json" rel="manifest">

index.html


I recommend this website if you want to learn more about manifest files:
https://web.dev/articles/add-manifest

Service Worker

Before we write our service worker, we install the Workbox library with npm

npm install workbox-core workbox-precaching workbox-routing workbox-strategies workbox-window
npm install workbox-cli -D

Next, create the file src/service-worker.ts. You can also create a subfolder inside src if your service worker is composed of different files, and you want to group them. The service worker we are going to write is a regular TypeScript application. We can import 3rd party libraries, and we can split the code into multiple files and import them.

Paste the following code into src/service-worker.ts

/// <reference lib="es2018" />
/// <reference lib="webworker" />
import {precacheAndRoute} from 'workbox-precaching';
import {clientsClaim, skipWaiting} from 'workbox-core';
import {registerRoute} from 'workbox-routing';
import {CacheFirst} from 'workbox-strategies';

declare const self: ServiceWorkerGlobalScope;

skipWaiting();
clientsClaim();

registerRoute(
  /assets\/images\/icons\/icon-.+\.png$/,
  new CacheFirst({
    cacheName: 'icons'
  })
);

precacheAndRoute(self.__WB_MANIFEST);

The code for this example is straightforward. We import the required objects and functions from the Workbox library. Then the application calls skipWaiting(), which forces the waiting service worker to become the active one, and together with clientsClaim() makes sure that the new service worker immediately takes over for both the current client and all other active clients. Be aware that this can lead to problems in certain circumstances.

Read more about these methods on MDN:
https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting
https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim

And I recommend this article about the lifecycle of service workers:
https://web.dev/articles/service-worker-lifecycle


Next, our service worker registers a cache first route for the icons. This route watches for requests from the browser that fetch icons. If the icon is already stored in the cache, the service worker sends back the cached resources. If it's not in the cache, the service worker sends the request to the server, caches the response, and then forwards the response back to the browser. We don't want to load all icons into the cache, only the ones the browser needs, that's the reason we handle them differently than the other application resources.

The important line is precacheAndRoute(self.__WB_MANIFEST);. With this method, the service worker registers routes, loads, and caches all the resources (CSS, JavaScript, Fonts, Images, ...) that are needed to run the Angular application.

You might be wondering why we pass self.__WB_MANIFEST as the argument to the method, shouldn't we pass a list of resources. That's correct, and it is what we have to do. But we don't know the names of the resources until the Angular CLI builds the application. The Angular build system creates versioned file names with a hash in their names, so the filenames are different each time we do a production build of our application.

This is the job of the Workbox CLI. It is going to run after Angular's production build, collects all the resources we want to precache, opens the service worker file, and replaces the string self.__WB_MANIFEST with an array of paths.


Note that your service worker does not have to look exactly like the example above. The only code you have to add is the declare line if you want to use self in your code. The TypeScript compiler complains and fails with an error if this statement is missing. And when you're goal is to create an offline application, you have to add the precacheAndRoute(self.__WB_MANIFEST); line.


In this blog post, we only use a small fraction of the Workbox library. If you want to learn more, visit the project page, there you find comprehensive documentation about all the provided modules and functions.

Load Service Worker

In this section, we add code that loads the service worker into the browser.

We can do this in different ways. A simple way is to add the following code to index.html.

<script>
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js');
  });
}
</script>

Another approach is to load the worker in main.ts. The method platformBrowserDynamic().bootstrapModule(AppModule) loads the Angular application and returns a Promise, that allows us to add code in then() that runs after the application has been initialized. This has the benefit that we can write the code in TypeScript. A simple loader could look like this.

import {enableProdMode} from '@angular/core';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {AppModule} from './app/app.module';
import {environment} from './environments/environment';

function loadServiceWorker(): void {
  // if (environment.production && ('serviceWorker' in navigator)) {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('service-worker.js')
      .catch(err => console.error('Service worker registration failed with:', err));
  }
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .then(() => loadServiceWorker())
  .catch(err => console.error(err));

main-simple.ts

Another benefit is that we have access to environment.production. We could wrap the code with an if statement and only load the service worker when the application runs in production.


In this example, I choose a slightly different approach. The application loads the service worker in main.ts, but uses another Workbox package: workbox-window

Unlike the other Workbox modules, workbox-window runs in the window context, and the goal is to simplify the service worker registration process and to prevent common mistakes. I recommend visiting the project page to learn more about this module.

The following code shows you an example of how you can load a service worker with workbox-window. You can use this module with any service worker, it does not have to be a worker written with Workbox.

import {Workbox} from 'workbox-window';

function loadServiceWorker(): void {
  // if (environment.production && ('serviceWorker' in navigator)) {
  if ('serviceWorker' in navigator) {
    const wb = new Workbox('service-worker.js');

    wb.addEventListener('activated', (event) => {
      if (!event.isUpdate) {
        console.log('Service worker activated for the first time!');
      } else {
        console.log('Service worker activated!');
      }
    });

    wb.addEventListener('waiting', (event) => {
      console.log(`A new service worker has installed, but it can't activate` +
        `until all tabs running the current version have fully unloaded.`);
    });

    wb.addEventListener('installed', (event) => {
      if (!event.isUpdate) {
        console.log('Service worker installed for the first time');
      } else {
        console.log('Service worker installed');
      }
    });

    wb.register();
  }
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .then(() => loadServiceWorker())
  .catch(err => console.error(err));

main.ts

Make sure that you install the package workbox-window. We already did that in a previous step. Import the Workbox object from the library, create a new object, pass the path to the worker, and then call the method register() to load and install the service worker. The example above also shows you how to listen for events.

Workbox CLI configuration

In our service worker, we added code to precache the application resources (precacheAndRoute). Workbox does not magically know what resources we want to cache. Instead, we have to tell the CLI which files belong to our application. This is what we do in this section.

In the root of our project, we create the file workbox-config.js and paste the following code:

module.exports = {
  globDirectory: "dist/app/",
  globPatterns: ["**/*.{css,eot,html,ico,jpg,js,json,png,svg,ttf,txt,webmanifest,woff,woff2,webm,xml}"],
  globFollow: true,
  globStrict: true,
  globIgnores: ['3rdpartylicenses.txt', 'assets/images/icons/icon-*.png'],
  dontCacheBustURLsMatching: new RegExp('.+.[a-f0-9]{20}..+'),
  maximumFileSizeToCacheInBytes: 5000000,
  swSrc: "dist/app/service-worker.js",
  swDest: "dist/app/service-worker.js"
};

workbox-config.js

This file tells the Workbox CLI what resources we want to precache.

You find a detailed description of all supported options on this page:
https://developer.chrome.com/docs/workbox/modules/workbox-build#.injectManifest

For this example, the CLI looks in the dist/app/ folder for every file that matches the globPatterns but does not match the globIgnores pattern.

We also ignore the icons in the assets folder. We only want to cache the icons that the browser needs, that's the reason we added registerRoute to our service worker code.

Workbox adds by default a cache-buster to URLs when it loads resources, but the Angular CLI already creates files with a hash in their name when we do a production build. Therefore, we tell Workbox CLI with the option dontCacheBustURLsMatching to not add a cache-buster to URLs of resources that already have a hash in their name. The regular expression looks for a 20 character hex string in the filename.

Webpack

Our service worker is written in TypeScript and does not run as is in a browser. So we have to add a build step that compiles the code into JavaScript and bundles everything together into one file. We do this with Webpack, the same bundler that Angular CLI utilizes for building the application.

First, we install the required packages

npm install rimraf ts-loader webpack webpack-cli -D

Because we only need the packages during build time, we install them as development dependencies.


Next, we create the configuration file for Webpack. The name and location of this file are not relevant. In this example, the file is called sw-prod-webpack.config.js and located at the root of the project.

Paste the following code into the file. Change the entry path if your service-worker.ts file is located somewhere else.

const path = require('path');

module.exports = {
  mode: 'production',
  entry: path.join(__dirname, 'src', 'service-worker.ts'),
  output: {
    path: path.join(__dirname, 'dist', 'app'),
    filename: 'service-worker.js'
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
        options: {
          onlyCompileBundledFiles: true
        }
      }
    ]
  },
  resolve: {
    extensions: ['.ts', '.wasm', '.mjs', '.js', '.json']
  }
};

sw-prod-webpack.config.js

This configuration tells Webpack to use the production mode, which includes a minification step (Terser). The output is stored in dist/app/service-worker.js, and the ts-loader rule is responsible for compiling the TypeScript code into JavaScript.

Scripts

We have configured the Workbox and Webpack CLI, and now we need a way to start both programs. We do this with a npm script. Open package.json and add the following script.

    "sw-prod-webpack": "rimraf ./dist/app/service-worker.js && webpack --config ./sw-prod-webpack.config.js --progress --color && workbox injectManifest ./workbox-config.js"

package.json

The script first deletes an old build output. Then it runs Webpack. Change the --config parameter if the path and name of the Webpack configuration file are different in your project.
Lastly, the script starts the Workbox CLI which replaces self.__WB_MANIFEST in the file /dist/app/service-worker.js with an array of resources.


Because we want to start this script after every production build, we add the following scripts to package.json.

    "build": "ng build",
    "postbuild": "npm run sw-prod-webpack",

package.json

We can now start a production build with npm run build, and npm automatically starts the postbuild script when the build finished successfully.

If everything is set up correctly, a production build should create the compiled and bundled service worker in the dist folder. Open /dist/app/service-worker.js to check if the injection of the precache resources worked and if all necessary resources are listed.

The part with the resources should look like this. In this example you find the code at the end of the file.

...

addToCacheList(e),e.length>0&&(self.addEventListener("install",x),self.addEventListener("activate",O))}
([{"revision":"b9aa7c338693424aae99599bec875b5f","url":"favicon.ico"},{"revision":"ddebce2a9b41bf0f8db6c50d7341e18a","url":"index.html"},
{"revision":null,"url":"main-es2015.542658ea6f5085762cfd.js"},{"revision":"97bd9ac47500d696788eb4e82d2f5eb3","url":"manifest.json"},
{"revision":null,"url":"polyfills-es2015.bbb42ff2e1c488ff52d5.js"},{"revision":null,"url":"runtime-es2015.c9afb3256f2870e161de.js"},
{"revision":null,"url":"styles.09e2c710755c8867a460.css"}]),K(A)}]);

If you still see the string self.__WB_MANIFEST or the wrong set of resources are listed check the build output and the patterns in the Workbox CLI file workbox-config.js.

Test production build

If you want to test the production build, you have to serve the files from an HTTP server, because you can't load service workers via the file:// protocol. Either deploy the build artifacts to an HTTP server or install an HTTP server locally on your development computer.

In this project, I use the local-web-server package that I installed with this command.

npm install local-web-server -D

You can then either start it with npx

npx ws --hostname localhost -d dist/app -p 1234

or add a script to package.json.

"serve-dist": "ws --hostname localhost -d dist/app -p 1234 -o --log.format stats",

and then start it with

npm run serve-dist

With this code, the HTTP server listens on port 1234, and it automatically opens the browser (-o).

Development

All the code and configurations we created so far target the production. If you only use the service worker for caching, this might already be everything you need. But a service worker can also do other things like receiving push notification or handling background sync requests. If you start working with these parts, you maybe also want to try them out during development.

In this section, we look at a way how to amend the build system so that we can test the service worker during development.

When we start our application with ng serve, the Angular CLI creates the build artifacts in memory. So we can't inject anything into the precache route of the service worker. And even if we could, it would make development inconvenient because we always have to clear the service worker cache to make sure we load the latest code.

Because we don't inject anything, the code precacheAndRoute(self.__WB_MANIFEST) throws an error because self.__WB_MANIFEST is undefined, and the method can't handle this case, it expects an array of strings as the argument.

To solve that, we wrap the caching code with an if and check the process.env.NODE_ENV environment. If the applications run in development, it skips the caching code.

if (process.env['NODE_ENV'] === 'production') {
  registerRoute(
    /assets\/images\/icons\/icon-.+\.png$/,
    new CacheFirst({
      cacheName: 'icons'
    })
  );

  precacheAndRoute(self.__WB_MANIFEST);
}

service-worker.ts

Webpack automatically sets process.env.NODE_ENV based on the mode option.


Next, we create a Webpack configuration for development. As the production config file, the name is not essential. I called it sw-dev-webpack.config.js and stored it in the root of the project.

const path = require('path');

module.exports = {
  mode: 'development',
  entry: path.join(__dirname, 'src', 'service-worker.ts'),
  output: {
    path: path.join(__dirname, 'src'),
    filename: 'service-worker.js'
  },
  watch: true,
  watchOptions: {
    ignored: /node_modules/
  },  
  module: {
    rules: [
      {
        test: /\.ts$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
        options: {
          onlyCompileBundledFiles: true
        }
      }
    ]
  },
  resolve: {
    extensions: ['.ts', '.wasm', '.mjs', '.js', '.json']
  }
};

sw-dev-webpack.config.js

Differences to the production build are mode, which is set to development instead of production, watch is enabled and the build output is src/service-worker.js

In development mode, Webpack does not run the TerserPlugin, which minifies the JavaScript code, and it sets the process.env.NODE_ENV environment variable to development, instead of production.

In watch mode, the Webpack process runs forever and observes the input file for changes. Every time the file service-worker.ts changes, Webpack compiles it into JavaScript and bundles everything together.


Webpack writes the compiled file to src/service-worker.js, but by default, we can't access this file from a browser when we run the application with ng serve. To make it accessible, we have to add it to the assets array in the Angular configuration

            "assets": [
              "src/favicon.ico",
              "src/assets",
              "src/manifest.json",
              "src/service-worker.js"
            ],

angular.json

This entry also tells the Angular CLI to copy the file to the dist folder during a production build. This is not a problem because, in the production build, we run the Webpack process that bundles the service worker after the Angular build step, so it overwrites the development service-worker.js with the production build.


Because src/service-worker.js is a generated file, I added it to .gitignore, so it will not be stored in the git repository.

/service-worker.js

.gitignore


The last thing we have to solve is the problem of how to start the development build. We need to start two background processes: ng serve to start the Angular application in development mode, and the Webpack process that watches and compiles src/service-worker.ts.

To solve that, we install the npm-run-all package. This package allows us to start multiple scripts in parallel.

npm install npm-run-all

Then we add a script that runs Webpack with the development configuration

    "sw-dev-webpack": "rimraf ./src/service-worker.js && webpack --config ./sw-dev-webpack.config.js",

package.json

And we add a script that starts ng serve and the Webpack build in parallel with npm-run-all.

    "dev": "npm-run-all --parallel sw-dev-webpack start",
    "start": "ng serve -o",

package.json

The command npm run dev starts both processes.


This concludes the tutorial about writing a simple caching service worker with Workbox and integrating it into an Angular application. Writing the service worker in this way gives us the most flexibility. We can write the code in TypeScript. We can import other modules, and thanks to the Workbox library, we can write the code with a high abstraction level API and don't have to deal with the low-level service worker API.

If you are interested in a more extensive example, check out my blog post about background sync. The example there utilizes the approach described in this article, but it also imports a 3rd party library and other modules into the service worker.