Notify Users With the New Version of Your Angular App

Nil Seri
9 min readNov 26, 2023

--

Check for App Version Updates with Service Worker in Angular

Photo by Elektra Klimi on Unsplash

Receiving live updates

Imagine an application such as an e-mail client which stays open for the whole working day. How do we publish a critical update in this situation? We cannot expect users to periodically refresh the page. Angular has provided us with the SwUpdate service. It allows us to manually check for updates while the application is running and subscribe for update events.

First of all, you need to add service worker to your existing Angular project:

ng add @angular/pwa

The changes you will see in your project are:

  1. @angular/service-worker will be added to your package.json
  2. Imports and registers the service worker with the application’s root providers, as the following will be added to imports section of your app.module.ts:
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: !isDevMode(),
// Register the ServiceWorker as soon as the application is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000'
})

isDevMode method returns whether Angular is in development mode. By default, this is true, unless enableProdMode is invoked prior to calling this method or the application is built using the Angular CLI with the optimization option.

You can also replace it with an environment variable such as “environment.production” or another custom parameter.

3. Updates the index.html file by adding a link to add the manifest.webmanifest file and a meta tag for theme-color.
I removed these since I do not need them at the moment since I am not actually implementing a progressive web app (PWA);
- theme-color is set as a given option to our users.
- browsers read the icon, title, theme colors and other web app configurations from this manifest.webmanifest file. We currently have these settings in our index.html file.
If you have manifest.webmanifest file in your project, you can preview it inside Chrome’s DevTools by opening the Application tab and clicking the Manifest menu item on the left. For more details about manifest, you can visit here.

4. Installs icon files to support the installed Progressive Web App (PWA). I removed these, too since they are used in manifest.webmanifest and nowhere else.

5. Creates the service worker configuration file called ngsw-config.json, which specifies the caching behaviors and other settings. This is the required file for informing the updates. I will be giving the details about how it works in a short while.

6. Also, there is a new flag serviceWorker set to true in the CLI configuration file angular.json. Under “architect” > “build” > “options”, the config is added as below:

"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"

About ngsw-config.json file

This is the declarative NGSW configuration. It helps static assets caching which should make the app load when there is no internet connection.

The content of the newly created ngsw-config.json file is:

{
"$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": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

index — specifies the file that serves as the index page to satisfy navigation requests. Usually this is /index.html.

assetGroups — each asset group specifies both a group of resources and a policy that governs them. This policy determines when the resources are fetched and what happens when changes are detected.

installMode — determines how these resources are initially cached. The installMode can be either of two values: prefetch, lazy.

You can read more about the configuration here.

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.

I emptied “assetGroups” content since we had cache issues and implemented cache busting. You can read more about cache busting in my previous post:

Now, ngsw-config.json file content is:

{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": []
}

If I keep the default config, then we can see the requested ngsw.json file (which I will go into detail in the next section) response in the Developer Tools > Network with Filter set to All. My project’s matching files in assetGroups config are listed as an output:

When “assetGroups” config is empty:

navigationRequestStrategy has two possible values: ‘performance’ and ‘freshness’. ‘performance’ is the default setting; serves the specified index file, which is typically cached. ‘freshness’ asses the requests through to the network and falls back to the performance’ behavior when offline.

Since I also do not want index.html file to be cached, I will add “navigationRequestStrategy” with value “freshness” to my ngsw-config.json file. Final content of ngsw-config.json file:

{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [],
"navigationRequestStrategy": "freshness"
}

About ngsw-worker.js file

In the context of an Angular service worker, a “version” is a collection of resources that represent a specific build of the Angular application.

Whenever a new build of the application is deployed, the service worker treats that build as a new version of the application. This is true even if only a single file is updated.

With the versioning behavior of the Angular service worker, an application server can ensure that the Angular application always has a consistent set of files.

File integrity is especially important when lazy loading. The filenames of the lazy chunks are unique to the particular build of the application (with hashes). If a running application at version X attempts to load a lazy chunk, but the server has already updated to version X + 1, the lazy loading operation fails (with “Error: Uncaught (in promise): Error: Loading chunk 0 failed” log on console). This is the situation I had written about in my Cache Busting in Angular post.

As you might have noticed there is no actual JavaScript file ngsw-worker.js. The build process creates it as well as the manifest file, ngsw.json, using information from ngsw-config.jsonat build time, under dist directory. The version is determined by the contents of the ngsw.json file, which includes hashes for all known content.

The only resources that have hashes in the ngsw.json manifest are resources that were present in the dist directory at the time the manifest was built.

If a particular file fails validation, the Angular service worker attempts to re-fetch the content using a “cache-busting” URL parameter to prevent browser or intermediate caching. You can see how your browser checks for updates via ngsw-worker.js in Developer Tools > Network with Filter set to All (such as the screenshots above)

SwUpdate Service

I created a new component just like Whatsapp’s:

https://webapps.stackexchange.com/questions/148582/rollback-whatsapp-web-update

For version update checks, I created a service to use in my component.

This is my component code:

import { Component, Input, ViewEncapsulation } from '@angular/core';
import { NewVersionCheckerService } from '@service/new-version-checker.service';

@Component({
selector: 'app-new-version-checker',
templateUrl: './new-version-checker.component.html',
styleUrls: ['./new-version-checker.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class NewVersionCheckerComponent {
@Input() containerClasses: string;

constructor(
public newVersionCheckerService: NewVersionCheckerService
) { }

applyUpdate(): void {
this.newVersionCheckerService.applyUpdate();
}
}
<div *ngIf="newVersionCheckerService.isNewVersionAvailable" class="{{containerClasses}}">
<div
class="d-flex align-items-center new-version-checker-container bg-main-color py-2 px-1 robot_newVersionChecker">
<div class="pr-2">
<button type="button" class="btn btn-icon-transparent" aria-label="Refresh" (click)="applyUpdate()">
<i class="fa-regular fa-arrows-rotate fa-gray fa-large"></i>
</button>
</div>
<div class="new-version-checker-info text-white">
<div class="s1-m mb-1 robot_newVersionChecker_title">
{{ 'UPDATE_CHECKER_TITLE' | translate }}
</div>
<div class="s1-r robot_newVersionChecker_information">
{{'UPDATE_CHECKER_INFORMATION' | translate}}
</div>
</div>
</div>
</div>

The service worker checks for updates during initialization and on each navigation request — that is, when the user navigates from a different address to your application. As an updated version is detected on the server, installed and ready to be used, you can subscribe to versionUpdates.

import { Injectable } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
import { Subscription } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class NewVersionCheckerService {
isNewVersionAvailable: boolean = false;
newVersionSubscription: Subscription;
constructor(
private swUpdate: SwUpdate
) {
this.checkForUpdate();
}
checkForUpdate(): void {
this.newVersionSubscription?.unsubscribe();
if (!this.swUpdate.isEnabled) {
return;
}
this.newVersionSubscription = this.swUpdate.versionUpdates.subscribe(evt => {
switch (evt.type) {
case 'VERSION_DETECTED':
console.log(`Downloading new app version: ${evt.version.hash}`);
break;
case 'VERSION_READY':
console.log(`Current app version: ${evt.currentVersion.hash}`);
console.log(`New app version ready for use: ${evt.latestVersion.hash}`);
this.isNewVersionAvailable = true;
break;
case 'VERSION_INSTALLATION_FAILED':
console.log(`Failed to install app version '${evt.version.hash}': ${evt.error}`);
break;
}
});
}
}

There are four possible event types:

VERSION_DETECTED: Emitted when the service worker has detected a new version of the app on the server and is about to start downloading it.
NO_NEW_VERSION_DETECTED: Emitted when the service worker has checked the version of the app on the server and did not find a new version.
VERSION_READY: Emitted when a new version of the app is available to be activated by clients. It may be used to notify the user of an available update or prompt them to refresh the page.
VERSION_INSTALLATION_FAILED: Emitted when the installation of a new version failed. It may be used for logging/monitoring purposes.

The logs of the service:

There is also “appData” field that you can add to ngsw-config.json file:

appData — enables you to pass any data you want that describes this particular version of the application. The SwUpdate service includes that data in the update notifications. Many applications use this section to provide additional information for the display of UI popups, notifying users of the available update.

You can use appData with versionUpdates’s event. For ‘VERSION_DETECTED’ and ‘VERSION_INSTALLATION_FAILED’, you can get via “event.version.appData”; and for ‘VERSION_READY’, you can use as “event.currentVersion.appData” and “event.latestVersion.appData”.

If you intend to manually check for updates, you can use checkForUpdate method in your service:

import { Injectable, NgZone } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
import { Subscription, interval } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class NewVersionCheckerService {
isNewVersionAvailable: boolean = false;
intervalSource = interval(15 * 60 * 1000); // every 15 mins
intervalSubscription: Subscription;

constructor(
private swUpdate: SwUpdate,
private zone: NgZone
) {
this.checkForUpdate();
}

checkForUpdate(): void {
this.intervalSubscription?.unsubscribe();
if (!this.swUpdate.isEnabled) {
return;
}

this.zone.runOutsideAngular(() => {
this.intervalSubscription = this.intervalSource.subscribe(async () => {
try {
this.isNewVersionAvailable = await this.swUpdate.checkForUpdate();
console.log(this.isNewVersionAvailable ? 'A new version is available.' : 'Already on the latest version.');
} catch (error) {
console.error('Failed to check for updates:', error);
}
});
})
}

applyUpdate(): void {
// Reload the page to update to the latest version after the new version is activated
this.swUpdate.activateUpdate()
.then(() => document.location.reload())
.catch(error => console.error('Failed to apply updates:', error));
}
}

When I tried the example code here, appRef.isStable never returned true. When I searched for the reason, I came across this:

Angular is in an unstable state, whenever there is some asynchronous callback left, that has to be executed.

For this, you can run the interval outside of Zone.js (with zone.runOutsideAngular), allowing the app to eventually get stable.

SwUpdate’s checkForUpdate method returns a Promise<boolean> which indicates if an update is available for activation. If it returns true, our component shows up on screen.

If we click on the component for refresh, SwUpdate’s activateUpdate method is called. This updates the current client (i.e. browser tab) to the latest version that is ready for activation. If it is successful, we should call document.location.reload(). Calling activateUpdate without reloading the page could break lazy-loading in a currently running app, especially if the lazy-loaded chunks use filenames with hashes, which change every version.

You can read more about SwUpdate class in Angular’s api reference guide, here.

Happy Coding!

--

--

Nil Seri

I would love to change the world, but they won’t give me the source code | coding 👩🏻‍💻 | coffee ☕️ | jazz 🎷 | anime 🐲 | books 📚 | drawing 🎨