Converting an Ionic/Angular Site into a Progressive Web App

This post originally appeared on my blog, here.

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

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

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

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

{
"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

./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.)

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

Don’t modify the compiled Angular code

"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

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

View service-worker logs

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

Files cached locally by Noded’s service worker.

Conclusion

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.