For the past year, I've been working on a web application called Noded. Noded is built in Angular on the Ionic framework and provides tools for building a personal tree of information. (If you're curious, you can try it out here.)

A screenshot from Noded.

Because Noded is meant to replace whatever note-taking application a person uses, it's important that it be available offline (on your phone, for instance). So, one of the goals for Noded was to make it work as a progressive web app so it could be loaded even when the client doesn't have Internet access.

For the uninitiated, a progressive web app (or PWA) is a type of web app that can make use of native-integration features like push notifications, storage, &c. On mobile platforms, this also enables the "Add to Home Screen" functionality which enables users to "install" a PWA to their device so it appears as a native application and opens in full-screen mode, rather than in a browser.


Noded, running as a PWA on my phone.

Service Workers

In order for a web app to become a PWA, it needs two things. First, it needs a web manifest, which tells the browser the location of all resources used by the web app, and other information like the icon and background color. Second, it needs to have a service worker registered. Service workers are event-based JavaScript programs that run in the background on a user's browser.

These background programs can run even when the app itself isn't open and enable things like offline mode and push notifications. Ever wonder how applications like Google Docs can still load even when the browser is offline? This is enabled by the service worker API.

Your application's service worker sits like a layer between your application and its back-end server. When your app makes a request to the server, it is intercepted by the service worker which decides whether it will be forwarded to the back-end, or retrieved from the local cache.

PWAs work offline by having the service worker cache all of their app resources offline automatically. Then, when the back-end server is unreachable, the resources are served from the service worker transparently to the application. Even when your app is online, service workers can dramatically speed up load times for people with slow or latent connections (especially those in developing areas).

Angular Service Worker

Because of their structured nature, Angular apps can make use of the Angular Service Worker which can automatically integrate with Angular apps to cache the built modules offline. This can be much easier to configure than writing a service-worker from scratch.

We'll start by adding the @angular/pwa package to our app, which will automatically bootstrap the manifest and service worker config:

ng add @angular/pwa --project app

(Where app is the name of your Angular project in angular.json.) This will create the ngsw-config.json config file, as well as the manifest in src/manifest.webmanifest.

ngsw-config.json

The Angular service worker can be configured through the ngsw-config.json file. By modifying this file, we can tell the service-worker for our app to automatically pre-fetch all assets for the application. That way, when the app goes offline, it can still load the front-end resources.

Note that the service-worker will cache other XHR headers with the proper cache headers, but if your application relies on API requests to start, you should account for that in the app's code using things like IndexedDB or localStorage.

{
  "$schema": "./node_modules/@angular/service-worker/config/schema.json",
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": [
          "/favicon.ico",
          "/index.html",
          "/manifest.webmanifest",
          "/*.css",
          "/*.js"
        ]
      }
    },
    {
      "name": "assets",
      "installMode": "prefetch",
      "updateMode": "prefetch",
      "resources": {
        "files": [
          "/assets/**",
          "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
        ]
      }
    }
  ]
}

Here's a sample config file. The index key specifies the entry-point to your application. For most Angular apps, this will be index.html since that's the file first loaded.

Then, the front-end assets are split into two groups. The app group matches any built files that are necessary to boot the Angular app. The assets group matches any additional assets like images, fonts, and external files.

In this example, I've set both groups to prefetch, which means that the service-worker will try to cache them in the background the first time the app is loaded. This ensures that they are always available offline, as long as they had time to load once. However, it can be more taxing for the first load.

To avoid this, you can set an asset group to installMode: lazy. This will cache the resources offline only once the front-end tries to load them.

Web Manifest

The @angular/pwa package will also generate a web manifest for your application in src/manifest.webmanifest. Here, you can customize things like your application's name, background colors, and icons:

{
  "name": "Noded",
  "short_name": "Noded",
  "theme_color": "#3A86FF",
  "background_color": "#fafafa",
  "display": "standalone",
  "scope": "./",
  "start_url": "./index.html",
  "icons": [
    {
      "src": "assets/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "assets/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "maskable any"
    },
...

Angular will auto-generate PWA icons in the assets/icons/ directory, so you'll want to customize those to match your app. These icons will become the home-screen icon for you app when a user installs it.


Noded's PWA icon when added to my home screen.

A few other notes about the web manifest:

  • The scope property defines the scope of pages in the web app that can be navigated to in the "app mode." If your app tries to load a route that's outside of the scope, the client will revert to a web-browser rather than immersive mode.
    • This property is relative to the entry point of the application. So, if the entry point is /index.html, then the scope ./* matches all routes /**.
  • The start_url is the route that is loaded when the user launches the PWA. Usually, this should match the entry point in the ngsw-config.json file as index.html.

Building your application

Now that we've set up the Angular service-worker, you should be able to build your app and have it appear as a PWA in the browser. You can do this as you normally would. Since Noded is an Ionic app, I'll use:

./node_modules/.bin/ionic build --prod

Using the ngsw-config.json, this will generate a few new files. If you look at www/ngsw.json, you can see the compiled config for the service-worker telling it the locations of all generated files for your app:

{
  "configVersion": 1,
  "timestamp": 1606842506052,
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "updateMode": "prefetch",
      "cacheQueryOptions": {
        "ignoreVary": true
      },
      "urls": [
        "/10-es2015.8900b72b6fdc6cff9bda.js",
        "/10-es5.8900b72b6fdc6cff9bda.js",
        "/11-es2015.82443d43d1a7c061f365.js",
        "/11-es5.82443d43d1a7c061f365.js",
        "/12-es2015.617954d1af39ce4dad1f.js",
        "/12-es5.617954d1af39ce4dad1f.js",
        "/13-es2015.eb9fce554868e6bda6be.js",
...

This is how the service-worker knows what to fetch and cache when running your application. It also writes the ngsw-worker.js file, which is the actual service worker code that gets run by the browser in the background. The web manifest is also included in the build.

Once you deploy your app and load it in the browser, it should now appear to have both a web manifest and a service worker:


You can view this on the "Application" tab of your browser's dev-tools.

Note that the service worker will only register and run if it is configured properly and your application is served over HTTPS.

Running in a sub-route (/app, &c.)

You may have noticed in the screen-shot above that the service-worker for Noded is registered for noded.garrettmills.dev/i. This is because the Angular app for Noded runs in the /i sub-route of the domain. This requires special consideration for the service-worker.

Recall that the manifest has a scope and start_url, and the ngsw.json has an index key. These are relative to the root of the domain, not the application. So, in order to serve our Angular app from a sub-route, we need to modify the PWA configs. Luckily, the Angular service-worker has a CLI tool that makes this easy for us. After we build our application, we can use the ngsw-config command to re-generate the config to use a sub-route:

./node_modules/.bin/ngsw-config ./www/ ./ngsw-config.json /i

The last argument is the sub-route where your application lives. In my case, that's /i. This command will modify the service-worker config to use the sub-route for all resources:

{
  "configVersion": 1,
  "timestamp": 1606843244002,
  "index": "/i/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "updateMode": "prefetch",
      "cacheQueryOptions": {
        "ignoreVary": true
      },
      "urls": [
        "/i/10-es2015.8900b72b6fdc6cff9bda.js",
        "/i/10-es5.8900b72b6fdc6cff9bda.js",
        "/i/11-es2015.82443d43d1a7c061f365.js",
        "/i/11-es5.82443d43d1a7c061f365.js",
        "/i/12-es2015.617954d1af39ce4dad1f.js",
        "/i/12-es5.617954d1af39ce4dad1f.js",
...

This ensures that your service worker caches the correct files. (Note that this doesn't actually need to modify the web manifest.)

Debugging

Once you've deployed your built app, it should start caching assets through the service-worker. However, if this doesn't happen, here are a few things to consider.

Don't modify the compiled Angular code

Once your app has been compiled to the www/ directory, never modify these files. If you need to make changes, use substitutions in the angular.json, or just change the original source files.

  "hashTable": {
    "/i/10-es2015.8900b72b6fdc6cff9bda.js": "d3cf604bab1f99df8bcf86d7a142a3a047c66dd2",
    "/i/10-es5.8900b72b6fdc6cff9bda.js": "8fcf65ea8740ae0364cd7371dd478e05eadb8b35",
    "/i/11-es2015.82443d43d1a7c061f365.js": "bc50afb2730b9662fc37a51ae665fd30a9b0637c",
    "/i/11-es5.82443d43d1a7c061f365.js": "300d5e62ec8ed5a744ac0dc1c2d627d6208499d7",
    "/i/12-es2015.617954d1af39ce4dad1f.js": "465dd6ae6336dee028f3c2127358eea1d914879d",
    "/i/12-es5.617954d1af39ce4dad1f.js": "5549d758aea47ab6d81a45d932993a6da9f5289c",
    "/i/13-es2015.eb9fce554868e6bda6be.js": "2ca9cc161ae45c0a978b8bebce3f6dd7597bba07",
    "/i/13-es5.eb9fce554868e6bda6be.js": "1dadc7f0083a1d499ea80f9c56d9ad62de96c4f3",
...

The reason for this is because the Angular service-worker generates hashes of the generated files and checks them on download. This is how it knows whether it has cached the latest version of the file or not. If you manually modify the compiled file, the hash won't match, and the service-worker will invalidate its entire cache.

Bypass the service-worker

As mentioned above, the service-worker will attempt to cache other outbound requests, provided that the server responds with appropriate cache headers. However, there may be instances where you want to prevent this behavior (for example, when checking if the app is online and can access the server). To do this, you can add the ?ngsw-bypass query parameter to the URLs of your requests.

Example: /api/v1/stat?ngsw-bypass.

View service-worker logs

If you are having issues with the service worker's cache, it can be difficult to narrow them down without logs. You can view debugging output from the Angular service-worker by navigating to the /ngsw/state route in your app. In my case, that's https://noded.garrettmills.dev/i/ngsw/state.

NGSW Debug Info:

Driver state: NORMAL ((nominal))
Latest manifest hash: none
Last update check: never



=== Idle Task Queue ===
Last update tick: never
Last update run: never
Task queue:


Debug log:

If you are having issues, the Debug log section can provide more info on cache invalidation and other issues.

View cached files

You can view the status of cached files in the "Storage" section of your browser's dev tools. This can help you see if the service worker was unable to find files (invalid route configurations), or was invalidating cached files.


Files cached locally by Noded's service worker.

Conclusion

This was a cursory look at getting your Angular/Ionic app set up as a PWA and caching assets offline using Angular service-workers. If your app relies on back-end resources (like an API), you'll still need to account for that when adding offline support using tools like IndexedDB and localStorage.

For example, Noded has an API service that sits between the app and the server and caches API resources locally in the IndexedDB. Perhaps we'll look into this more in a future post.