Create Progressive Web Apps with Angular Service Worker

18 minutes read

What is a Service Worker?

Service workers allow web developers to build rich web applications which closely resemble native apps in functionality. Google even coined a relevant term which describes such experiences - Progressive Web App, or more commonly known as PWA. Integrating service workers in the web app will allow it to work offline, receive push notifications and run in the background. If your app meets certain criteria, browsers will show the user an install prompt and the app can be launched from their device's home screen similarly to a native application. Registering a service worker is an essential element of the criteria.

Having the app installed with its own launcher icon will increase user engagement, but it will also raise certain expectations. Since we will add a service worker script, why not go all the way and implement content caching which will enable the app to work without web connectivity? Service workers can intercept all browser HTTP requests issued by the web app, moreover, through using its caching APIs we can serve local content from cache instead of the network. One of the most common uses of Service workers is speeding up initial loading time by caching all static content required by our web app: HTML, JavaScript, CSS, fonts and images. After our service worker has cached them, we can serve them from the local storage instead of sending a network request.

However, setting up even this basic caching strategy is quite the cumbersome task - you must be familiar with the various service worker lifecycle events and the Cache API. On top of that, there is a risk of shooting yourself in the foot there - if you are not that careful, you can deploy a faulty service worker script which makes it impossible for the app to update itself, and so, your users will remain stuck on a specific version of your app forever.

Angular's solution for Service Workers - NGSW

Luckily for all, Google’s Angular team has developed a package which handles all the boilerplate required for deploying a useful service worker. It’s called NGSW (ANGular ServiceWorker) and is installable via the @angular/service-worker npm package. NGSW can be integrated seamlessly within most Angular apps and its initial setup does not demand much effort from developers.

Let's create a sample PWA built with Angular and using NGSW. I’m going to keep it simple by reducing the amount of code to minimum and will intentionally focus on the Service worker functionality.

Setting up a sample Angular PWA

If you don't have @angular/cli installed, install it first:

npm i -g @angular/cli

Make sure Angular CLI was installed successfully by running:

ng version

You should see the Angular CLI and Angular's versions. At the time of writing the latest Angular version is 10.0.14 and Angular CLI is 10.0.8.

The next step is to create a new project:

ng new dogs-pwa --style=scss --routing --minimal --prefix=''

This will scaffold a project named dogs-pwa which has SCSS as its styles' preprocessor, default routing and no unit tests.

Now let's add the packages which will transform our project into a PWA.

ng add @angular/pwa --project dogs-pwa

This will install @angular/service-worker as a npm dependency, create a few new files and change already existing configuration files.

The most important new files which we will be looking at are:

  • manifest.webmanifest - the web manifest file. Browsers read the icon, title, theme colors and other web app configurations from this file. You can preview it inside Chrome's DevTools by opening the Application tab and clicking the Manifest menu item on the left.

  • ngsw-config.json - this is the declarative NGSW configuration. It has static assets caching which should make the app load when there is no internet connection.

And so, the changed parts are as follows:

  • src/app/app.module.ts - ServiceWorkerModule.register() is added to the AppModule imports list. It will register the service worker if it is supported by the browser and if the environment is production. These are sane defaults but we will change them later.

    app.module.ts
    @NgModule({
      declarations: [AppComponent],
      imports: [
        BrowserModule,
        AppRoutingModule,
        ServiceWorkerModule.register("ngsw-worker.js", {
          enabled: environment.production,
        }),
      ],
      providers: [],
      bootstrap: [AppComponent],
    })
    export class AppModule {}
  • src/index.html - a link to the web manifest is added in the head tag.

    index.html
    <link rel="manifest" href="manifest.webmanifest" />
    <meta name="theme-color" content="#1976d2" />

Make sure everything is working by starting the development server:

ng serve --watch

If you open http://localhost:4200 in your browser you should see the Angular logo and the project's name as title. However, if you try to load the web app in offline mode, you will see your browser's offline error message page. But don't worry, this is normal! NGSW can only be tested in production mode - you must build a distribution bundle and serve its contents with an HTTP server. Yes, this is cumbersome and time-consuming, but that is price you pay in this instance.

How does NGSW work?

As you might have noticed there is no actual JavaScript file ngsw-worker.js. So where is it? The actual Service Worker code which will run in the browser is generated at build time from the ngsw-config.json file. The Angular team did the tedious work to wire up the Service Worker logic needed to cover the most popular use-cases - static asset caching, checking for updates, dynamic endpoints (REST API) caching, preloading of critical resources, etc. What is left for a developer is to declare what to cache and when. Let's build the app for production and see what the default configuration does by exploring the distribution files.

ng build --prod && cd dist/dogs-pwa && ls -lah

There are a few files which should attract our attention:

  • manifest.webmanifest - the web manifest which is unchanged compared to the source file.
  • ngsw.json - the compiled version of the Angular Service Worker configuration file. You can see that every file matching the regular expressions in ngsw-config.json is listed here. There is also other metadata which NGSW is using at runtime.
  • ngsw-worker.js - the actual Service Worker JavaScript file - about 2000 lines of code you didn't have to write.
  • safety-worker.js - if you screw up big time, this is your escape hatch. I hope you never have to use this, but here are the detailed instructions.

We must serve the contents of the folder via an HTTP server. You can use whatever you want - nginx, Apache httpd or http-server. I will be using the last one since it is the simplest method and requires almost no configuration. You can follow along with the HTTP server you are most comfortable with.

Make sure to start the HTTP server in the directory which contains our distribution files (dist/dogs-pwa).

npx http-server -p 1337 .

Open your local HTTP server in browser - http://localhost:1337. After this navigate to the Application tab in Chromes's DevTools and open the Service Workers panel. You should see that there is an active Service Worker running for the currently opened page. Now turn on "offline mode" in the Network tab and refresh the page.

How to toggle offline mode in Chrome's DevTools

The page should load as if you had internet connection. Congrats, all the work paid off - you now have a working Service Worker, no pun intended. From now on we must toggle between offline and online modes whenever we make changes to the code, in order to make sure everything works as expected.

Updating our PWA

Currently our users download the static assets over the network only the first time they open it. This is great for performance whenever network conditions are not optimal. However, there is a catch - users will always run the code which they initially downloaded. How do we publish a critical update - for example fixing a major bug in our code which affects all users? If we didn't install the @angular/service-worker package, we would have to wire up the checking for updates logic by ourselves. Updating our Angular PWA is as easy as creating a new production build. The NGSW will automatically check if there are changes and will initialize an update if there are any.

Let's see it in action. Try changing the contents of the app.component.ts file. For example, remove the boilerplate HTML and replace it with a simple <h1> tag:

app.component.ts
@Component({
  selector: "app-root",
  template: `
    <h1>Dogs are awesome!</h1>
    <router-outlet></router-outlet>
  `,
})
export class AppComponent {}

Build for production and then refresh the page. You should see our recent changes applied.

NGSW checks if there is an updated version at the application’s start. It detects changes by comparing the hashed (SHA1) contents of the ngsw.json file with the previous one. The check is done by issuing a HTTP request which explicitly goes through any browser caching by appending a random number to the URL (called cache busting). An example cache busting request sent looks like this one GET http://localhost:1337/ngsw.json?ngsw-cache-bust=0.4517605577204291.

If there are changes in the ngsw.json, all affected resources are downloaded anew and added to the cache.

Receiving live updates

What if we want our PWA to update itself while it's being used? Imagine the app as an email client which stays open for the whole working day. How do we publish a critical update in this situation? We cannot expect people to periodically refresh the page - isn't that what we are trying to avoid with SPA architecture in the first place? Fortunately, the Angular team has thought about this use-case and provided us with the SwUpdateservice. It allows us to manually check for updates while the application is running and subscribe for update events. Let's see it in action and implement automatic check for updates every hour:

app.component.ts
export class AppComponent implements OnInit {
  private readonly EVERY_HOUR = 3600 * 1000

  constructor(public swUpdate: SwUpdate, private zone: NgZone) {}

  ngOnInit(): void {
    // running the interval outside of Zone.js allows the app to eventually get stable:
    // https://angular.io/api/core/ApplicationRef#isstable-examples-and-caveats
    this.zone.runOutsideAngular(() => {
      setInterval(() => {
        this.swUpdate.checkForUpdate()
      }, this.EVERY_HOUR)
    })

    // subscribe for app updates available
    this.swUpdate.available.subscribe(async () => {
      const updateNow = window.confirm(
        `New version available! Update now and restart the app?`
      )

      if (updateNow) {
        // trigger the update - this will download all changed files
        await this.swUpdate.activateUpdate()
        // refresh the page so that the new files become active
        document.location.reload()
      }
    })
  }
}

In more advanced cases you can display the exact version, which is to be installed as well as a changelog, so that users are being informed of the update’s implications. There is a special key-value property in the NGSW configuration file called appData where we can declare various metadata for our application:

ngsw-config.json
{
  // ...
  "appData": {
    "version": "0.0.2",
    "changelog": "This update fixes a serious bug."
  }
  // ...
}
app.component.ts
this.swUpdate.available.subscribe(async update => {
  const { version, changelog } = update.available.appData as {
    [key: string]: string
  }

  const updateNow = window.confirm(
    `New version (${version}) is available!\nChangelog: ${changelog}\nUpdate now?`
  )

  // ...
})

Static caching of external assets

The default NGSW configuration caches all static assets served locally by our HTTP server - all files in /assets and all images and fonts in the root folder. The assets are declared in the asset group called assets and are fetched on demand (when they are first requested) and then later added to cache. The other asset group is named app and contains application critical files (JS, HTML & CSS) which are preloaded and cached on start.

While the defaults will work for most of the cases, let's go a step further and add caching for external assets - fonts loaded from Google Fonts instead of our own server. This is a common case and it has some performance benefits like using Google's CDN and utilizing the native browser caching.

Let's add a cool looking font for our headings from Google Fonts. Open index.html and add the following in the end of the <head> tag:

The default NGSW configuration caches all static assets served locally by the HTTP server - all files in /assets and all images and fonts in the root folder. The assets are declared in the asset group called assets and fetched on demand (when they are first requested) and then later added to cache. The other asset group is named app and has application critical files (JS, HTML & CSS) which are preloaded and cached on start.

While the defaults will work for most of the cases, let's go a step further and add caching for external assets - fonts loaded from Google Fonts instead of our own server. This is the usual case which has some performance benefits like using Google's CDN and utilizing the native browser caching.

Let's add a cool looking font for our headings from Google Fonts. Open index.html and add the following in the end of the <head> tag:

index.html
<link
  href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap"
  rel="stylesheet"
/>

This will load a fancy font called Pacifico from Google Fonts. Now let's make sure that all the headings use this font. We are intentionally making the fallback font to serif in order to easily recognize whether the font loads correctly.

styles.scss
h1,
h2,
h3,
h4,
h5,
h6 {
  font-family: "Pacifico", serif;
}

If we serve the app in development mode, you will see whether the new font is applied. However, the production version will not be able to use the font in offline mode and will fall back to the default serif one. After debugging the failed HTTP requests in DevTools we can see that the following request to Google Fonts is failing - https://fonts.googleapis.com/css2?family=Pacifico&display=swap. Let’s add this to the list of assets in ngsw-config.json and see if anything changes. Spoiler alert - it won't work again because the CSS file tries to load the font itself from another URL located on a different host: https://fonts.gstatic.com/s/pacifico/v16/FwZY7-Qmy14u9lezJ-6H6MmBp0u-.woff2. We must add this origin as well.

ngsw.json
{
  "assetGroups": [
    ...,
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": [
          "/assets/**",
          "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
        ],
        "urls": [
          "https://fonts.googleapis.com/*",
          "https://fonts.gstatic.com/s/*"
        ]
      }
    }
  ]
}

Now the custom font works as expected even when we are offline. You can cache external images or sounds using the same strategy.

Adding REST API response caching

Serving static content from cache instead of the network is cool, but most web apps are using some external service to fetch their data from. Our current configuration does not support caching of external HTTP resources.

Let's create an Angular service which calls an external REST API and see what happens when we are offline. Since we named our project dogs-pwa we should stick to the name and show some dogs. We will use The Dog API to list some images of dogs.

ng g s services/dog

This will scaffold a new service called DogService where we will put the logic for fetching dog data. Before implementing the functionality of the service we will add a new TypeScript file named types.ts where we will put our custom types.

types.ts
/**
 * Query parameters for REST collections.
 */
export interface IRestCollectionQuery {
  page?: number
  pageSize?: number
}

Now open the services/dog.service.ts file and add the method which will fetch dog images:

dog.service.ts
import { Injectable } from "@angular/core"
import { HttpClient } from "@angular/common/http"
import { IRestCollectionQuery, IRestCollection } from "../types"
import { map } from "rxjs/operators"

const API_URL = "https://dog.ceo/api"

@Injectable({
  providedIn: "root",
})
export class DogService {
  constructor(private http: HttpClient) {}

  getImages(breed: string, query: IRestCollectionQuery = {}) {
    return this.http
      .get<{ message: string[] }>(`${API_URL}/breed/${breed}/images`)
      .pipe(
        map(response => {
          // simple client-side pagination
          const urls = response.message.slice(0, 125)
          const totalItems = urls.length
          const page = query.page || 1
          const pageSize = query.pageSize || 10
          const pageCount = Math.ceil(totalItems / pageSize)
          const items = urls.slice((page - 1) * pageSize, page * pageSize)

          const collection: IRestCollection<string> = {
            items,
            pageCount,
            page,
            pageSize,
            totalItems,
          }

          return collection
        })
      )
  }
}

To use the Angular's built in HTTP client we must import its module in our AppModule:

app.module.ts
@NgModule({
  ...,
  imports: [
    ...,
    HttpClientModule,
  ],
  ...
})
export class AppModule {}

We will also need a new component which will render the list of dog images.

ng g c components/dog-images-list
dog-images-list.component.ts
import { Component, Input } from "@angular/core"

@Component({
  selector: "dog-images-list",
  template: `
    <ul>
      <li *ngFor="let imageUrl of images">
        <img [src]="imageUrl" alt="Dog" />
      </li>
    </ul>
  `,
  styles: [
    `
      ul {
        list-style: none;
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        justify-content: center;
      }
      li {
        margin: 16px;
      }
      img {
        height: 160px;
        box-shadow: 2px 2px 16px 3px rgba(0, 0, 0, 0.23);
        border-radius: 4px;
      }
    `,
  ],
})
export class DogImagesListComponent {
  @Input() images: string[] = []
}

I'm intentionally inlining HTML and CSS since the component is simple enough. It takes a list of image urls as an input and renders each image as a list item. Because we have used the Angular CLI to generate the component it will automatically get imported in AppModule.

Now let's edit AppComponent and include our new component and service:

app.component.ts
import { Component, OnInit, NgZone } from "@angular/core"
import { SwUpdate } from "@angular/service-worker"
import { DogService } from "./services/dog.service"

@Component({
  selector: "app-root",
  template: `
    <h1>Dogs are awesome!</h1>
    <dog-images-list [images]="dogImages"></dog-images-list>
    <router-outlet></router-outlet>
  `,
  styles: [],
})
export class AppComponent implements OnInit {
  private readonly EVERY_HOUR = 3600 * 1000

  public dogImages: string[] = []

  constructor(
    private swUpdate: SwUpdate,
    private dogService: DogService,
    private zone: NgZone
  ) {}

  ngOnInit(): void {
    this.zone.runOutsideAngular(() => {
      setInterval(() => {
        if (!this.swUpdate.isEnabled) {
          return
        }

        this.swUpdate.checkForUpdate()
      }, this.EVERY_HOUR)
    })

    this.swUpdate.available.subscribe(async update => {
      const { version, changelog } = update.available.appData as {
        [key: string]: any
      }

      const updateNow = window.confirm(
        `New version (${version}) is available!\nChangelog: ${changelog}\nUpdate now?`
      )

      if (updateNow) {
        await this.swUpdate.activateUpdate()
        document.location.reload()
      }
    })

    // fetches images of dogs from the Borzoi breed
    this.dogService
      .getImages("borzoi", { page: 1, pageSize: 10 })
      .subscribe(response => {
        this.dogImages = response.items
      })
  }
}

If you start the development server - ng serve --watch and open localhost:4200 in your browser, you should see that 10 images of dogs are displayed. However, if we deploy to production and serve our app, you will notice that there are no dogs displayed now. The HTTP request which fetches the list of image URLs fails because it was not cached by NGSW. Let's change that and add a new "data group" which will handle all GET requests to the Dog API so that dogs will be fetched even while we are offline. The urls property contains a list of globs which when matched will result in the response content being cached. It's also a good idea to explicitly set the cacheConfig object. For the REST API we will use the freshness strategy - the requests will first go through the network and will get cached on success. If we are offline or if the response takes more time than the set timeout, the responses will be taken from cache. This strategy is perfect for dynamic resources like records from a database which may change at any time. The maxAge property means that after the provided time has passed, the cache entries will get removed. Combined with maxSize - the maximum allowed unique cache entries for this data group we can prevent the cache disk size from growing to unlimited amounts. Let's make NGSW wait for 2 seconds before serving responses from cache and keep only the latest 100 unique requests for maximum of 7 days.

Let's start the development server again:

ng serve --watch

Open http://localhost:4200 in your browser and you should see that 10 images of dogs are on display. However, if we deploy to production and serve our app, we will notice that there are no dogs displayed in this case. The HTTP request which fetches the list of image URLs fails because it was not cached by NGSW. Let's change that and add a new "data group" which will handle all GET requests to the Dog API so that dogs will be fetched even while we are offline.

The urls property contains a list of globs which when matched will result in the response content being cached. It's also a good idea to explicitly set the cacheConfig object. For the REST API we will use the freshness strategy - the requests will first go through the network and will get cached on success. If we are offline or if the response takes more time than the set timeout, the cache will be used. This strategy is perfect for dynamic resources like records from a database which may change at any time. The maxAge property means that after the provided time has passed, the cache entries will be removed. Combined with maxSize - the maximum allowed unique cache entries for this data group we can prevent the cache disk size from growing to unlimited amounts. Let's make NGSW wait for 2 seconds before serving responses from cache and keep only the latest 100 unique requests for maximum of 7 days.

ngsw-config.json
{
  ...
  "dataGroups": [
    {
      "name": "api",
      "cacheConfig": {
        "strategy": "freshness",
        "timeout": "2s",
        "maxAge": "7d",
        "maxSize": 100
      },
      "urls": ["https://dog.ceo/api/*"]
    }
  ],
  ...
}

If we build for production again and deploy to our HTTP server, we will notice that the request does not fail, but the images themselves are not loaded. This is because the images are served from a CDN which has a different URL than the one we declared above - https://images.dog.ceo/. In order to cache the images we have to add that URL to our config as new data group. Append the following object in the dataGroups array:

ngsw-config.json
{
  "name": "images-cdn",
  "cacheConfig": {
    "strategy": "performance",
    "maxAge": "14d",
    "maxSize": 100
  },
  "urls": ["https://images.dog.ceo/*"]
}

For this case we will use the performance strategy which makes NGSW return results from cache for already cached requests without accessing the network at all. This strategy is useful for resources which never change, or which do not change often - list of supported languages, payment methods, product categories, customer avatars and so on.

If we now build for production and deploy the new version of our app, we should see that the dogs are listed even when we are offline.

Where NGSW falls short

I would never recommend anyone who uses Angular to implement a Service Worker script themselves - there are just too many ways to shoot yourself in the foot. NGSW is awesome and battle tested. However, there are many use-cases which are not automatically handled:

  • subscribing for and receiving push notifications
  • background updates
  • custom filters for caching - e.g. User A should not see cached content from User B who previously used the app from the same browser.
  • custom caching strategies - for example Stale While Revalidate - immediately returning response from cache while sending a background request to refresh the content and then replace the response with the newest content available. It's a great caching strategy which covers large amount of use-cases but cannot be executed by NGSW.

In future articles I will deconstruct these use-cases one by one and implement them by extending NGSW’s functionality.

Stay tuned!