Mert Özen Further With Every Line
Frontend Browser APIs and PWA — #05

An Offline-Capable App with the Angular Service Worker: ngsw-config, dataGroups, SwUpdate and Safe Deploys

Mert Özen Nov 15, 2026 13 min 10 views
An Offline-Capable App with the Angular Service Worker: ngsw-config, dataGroups, SwUpdate and Safe Deploys

The Angular Service Worker caches app files with assetGroups and API responses with dataGroups, and detects versions from the hashed ngsw.json the build produces; you announce a new version with SwUpdate.versionUpdates and let the user do a full reload. On a production build we show the offline scenario, VERSION_READY, the unrecoverable state after old files are deleted, deploy cache headers and withdrawing the worker with safety-worker.js in Chromium and WebKit.

Version note: The examples in this post were run with Angular 22.2 (TypeScript 6.0), @angular/service-worker 22.2.6, @angular/pwa 22.2.7, Node.js 24 and Playwright 1.65; browser output comes from Playwright's Chromium 158 and WebKit 27.2 builds, on a production build.

Predict First

A service worker was added to the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, and the catalog API was put into a dataGroups entry in ngsw-config.json:

{
  "name": "catalog",
  "urls": ["/api/books", "/api/books/*"],
  "cacheConfig": {
    "strategy": "freshness",
    "maxSize": 100,
    "maxAge": "1d",
    "timeout": "3s"
  }
}

The user opens the app online, looks at the catalog and at Dune's page, and opens "My orders". Then the server goes down. The user opens, in order, the catalog, Dune, Emma (which they never opened before) and "My orders". Which of them work?

In the previous post we wrote a service worker by hand and managed the version constant, cache names and strategies ourselves. The Angular Service Worker, NGSW for short, does the same jobs with a build step and a configuration file. In this post we look, on a real production build, at what ng add @angular/pwa adds, at assetGroups and dataGroups, the hash-based ngsw.json, telling the user about a new version with SwUpdate, the unrecoverable state, and withdrawing the worker with safety-worker.js.

What Does ng add @angular/pwa Add?

npx ng add @angular/pwa --skip-confirmation

The command added @angular/service-worker to the dependencies, created ngsw-config.json, public/manifest.webmanifest and eight icon files, put the manifest link into index.html, and wrote "serviceWorker": "ngsw-config.json" into the build options in angular.json. The change on the application side is in app.config.ts:

import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { provideServiceWorker } from '@angular/service-worker';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes, withComponentInputBinding()),
    provideServiceWorker('ngsw-worker.js', {
      enabled: !isDevMode(),
      registrationStrategy: 'registerWhenStable:30000',
    }),
  ],
};

Two details matter. Because of enabled: !isDevMode(), the worker is never registered under ng serve; to try it you need a production build served from a real HTTP server. Bookstore's mock API does this with the --app parameter (npm run serve:app), so the build and /api share one origin. registerWhenStable:30000 delays registration until the app is stable, at most 30 seconds, so the worker downloading every file does not compete with the first load's network traffic. The manifest and icons belong to the installable PWA; we return to them in the next post.

assetGroups and the Hash-Based ngsw.json

The assetGroups that ng add generated split the app's own files into two groups: the app group (index.html, all .js and .css) is downloaded up front while the worker installs, with installMode: "prefetch"; the assets group (images, fonts) is lazy, so it enters the cache only when requested. ng build reads this configuration and writes a manifest called ngsw.json into dist. A shortened version:

{
  "configVersion": 1,
  "timestamp": 1794684569894,
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "urls": [
        "/chunk-5Bq5Z2tF.js",
        "/chunk-C872WQBo.js",
        "/chunk-C9P7jqt2.js",
        "..."
      ]
    }
  ],
  "dataGroups": [
    {
      "name": "catalog",
      "patterns": [
        "\\/api\\/books",
        "\\/api\\/books\\/[^/]*"
      ],
      "strategy": "freshness",
      "maxSize": 100,
      "maxAge": 86400000,
      "timeoutMs": 3000,
      "cacheQueryOptions": {
        "ignoreVary": true
      },
      "version": 1
    }
  ],
  "hashTable": {
    "/chunk-5Bq5Z2tF.js": "f1f1fff3cf912f59e2bed0fc6cf56851328eec67",
    "/chunk-C872WQBo.js": "9b8e0ebfb2989071c5bf15e8587b29001e20b348",
    "/chunk-C9P7jqt2.js": "cab04d1624d28d99cee6fcd718e983b1452b26d8",
    "...": "..."
  }
}

This file replaces the VERSION constant of the previous post. Every file's SHA-1 hash is in hashTable; the worker compares the ngsw.json on the server with the one it knows, downloads a new version if they differ, and verifies the hash of every file it downloads. The globs were turned into real file names during the build, and the URLs in dataGroups into regexes. One default stands out: Angular adds ignoreVary: true to cacheQueryOptions on its own. In the previous post we saw Vary: Authorization keep personal responses apart; NGSW's cache ignores that header. That is why personal endpoints must not go into any dataGroups entry.

dataGroups: freshness and performance

dataGroups are for API responses. Only GET and HEAD requests are cached, and there are two strategies. performance never goes to the network when the cache has a response (staleness is bounded by maxAge) and suits rarely changing data. freshness goes to the network first and serves the cached response if no answer arrives within timeout or there is no network; it is the right choice for changing data such as prices and stock. According to the Angular documentation, freshness with timeout: "0u" gives behaviour close to stale-while-revalidate. maxSize limits the number of entries; an unbounded cache can fill the quota and get the whole origin evicted. I played the Predict First scenario with Playwright; the first visit and NGSW's /ngsw/state debug page:

--- 1. first visit (v1)
heading "Catalog", controlled by /ngsw-worker.js
orders online: Orders: 1051
NGSW Debug Info:

Driver version: 22.2.6
Driver state: NORMAL ((nominal))
Latest manifest hash: e4bedf302455fd5e2ba00d18eeab034eec99f1d8
Last update check: 123u

=== Version e4bedf302455fd5e2ba00d18eeab034eec99f1d8 ===

Clients: 864390d5-ffeb-4505-80c6-233de942c580, 67ba2ab3-eda9-4567-90d6-ca0c0983fe65, 413c9794-7513-4920-ac1a-e347fb48e970

=== Idle Task Queue ===

After the server went down:

--- 2. server stopped
/: Catalog | Dune: TRY450.00
/books/9780441172719: Dune | Frank Herbert, TRY450.00
/books/9780141439587: This book could not be loaded. | Reading list
/orders: My orders | Your orders need a connection. Please try again when you are online.

The catalog and Dune worked: the app files came from the app group, the API responses from the catalog data group's cache. Emma did not, because dataGroups keep only responses to requests that were actually made; the detail of a book never opened is not in the cache. "My orders" deliberately did not work: /api/me/orders is in no data group, the request goes from the worker straight to the network, and without a network the page shows its own error message. Showing a stale copy of order and payment data is worse than showing none. If there are detail pages you want available offline, you have to request them in advance, for example by loading the books in the user's reading list once when the app starts.

Keep in mind that data group requests also go through the browser's HTTP cache. In NGSW's source, a data group request is sent with the same cache settings as the page's request; if the API sends max-age, the response freshness gets "from the network" can also come from the HTTP cache, as we saw in the previous post.

Since Angular is a SPA, an address such as /books/9780441172719 is not a file on the server; NGSW answers navigation requests with the cached index.html. navigationUrls decides which URLs count as navigations. Its default is in the build output: every URL is included, except those whose last segment contains a dot (looking like a file name) and those containing __. If there are pages the server itself must produce (a server-side screen under /admin, or a payment provider's return address), exclude them with a rule such as "!/admin/**"; otherwise the worker returns Angular's index.html for that address too.

navigationRequestStrategy is performance by default: a navigation is answered with the cached index.html and does not go to the server. If the server redirects navigations to a login page, or the app uses SSR, freshness goes to the network first. Be aware of this in an SSR app: once the worker is installed, an NGSW that answers navigations with the cached shell never delivers the HTML the server produces, and the page is rendered on the client. That is also why ng add added /index.csr.html to the list; in SSR projects that file is the client-side shell.

Saying "A New Version Is Ready" with SwUpdate

NGSW also follows the lifecycle of the previous post, but it manages versions internally: when it finds a new ngsw.json, it downloads the new version in the background and open tabs keep running the old one. Telling a tab about it is SwUpdate's job:

import { ApplicationRef, inject, Service, signal } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';

@Service()
export class AppUpdates {
  private readonly updates = inject(SwUpdate);
  readonly ready = signal(false);
  readonly broken = signal('');

  constructor() {
    if (!this.updates.isEnabled) return; // ng serve, or a browser without service workers

    this.updates.versionUpdates.subscribe((event) => {
      if (event.type === 'VERSION_DETECTED') {
        console.log(`update: VERSION_DETECTED ${event.version.hash.slice(0, 8)}`);
      } else if (event.type === 'VERSION_READY') {
        console.log(
          `update: VERSION_READY ${event.currentVersion.hash.slice(0, 8)} -> ${event.latestVersion.hash.slice(0, 8)}`,
        );
        this.ready.set(true);
      } else if (event.type === 'VERSION_INSTALLATION_FAILED') {
        console.log(`update: VERSION_INSTALLATION_FAILED ${event.error}`);
      } else {
        console.log(`update: ${event.type}`);
      }
    });

    // the version serving this tab lost a file it needs; only a full reload helps
    this.updates.unrecoverable.subscribe((event) => {
      console.log(`unrecoverable: ${event.reason}`);
      this.broken.set(event.reason);
    });

    // besides the check the service worker makes on each navigation, check every 6 hours
    void inject(ApplicationRef)
      .whenStable()
      .then(() => setInterval(() => void this.check(), 6 * 60 * 60 * 1000));
  }

  async check() {
    try {
      console.log(`checkForUpdate: ${await this.updates.checkForUpdate()}`);
    } catch (err) {
      console.log(`checkForUpdate failed: ${err}`);
    }
  }

  reload() {
    document.location.reload();
  }
}

When ready() becomes true, the app shell shows a "A new version of Bookstore is ready" banner with a Reload button. I changed the heading, made a new build (since Angular's build cleans dist, the old hashed files were deleted from the server) and opened the page again:

--- 3. new version
--- deployed v2: ng build, old hashed files deleted
update: VERSION_DETECTED d598b917
update: VERSION_READY e4bedf30 -> d598b917
heading "Catalog", banner: A new version of Bookstore is ready. Reload
after Reload: heading "Catalog (v2)"

During the navigation the worker saw the new ngsw.json (VERSION_DETECTED), downloaded the files and verified their hashes (VERSION_READY), yet the page still shows the old heading: this tab is bound to the old version. After Reload the new version arrived. NGSW makes this check on its own on every navigation; checkForUpdate() is for users who leave the page open for days. The example in Angular's service worker guide also starts this check every six hours once the app is stable. The reason is zone.js apps: a setInterval started at startup keeps such an app from ever becoming stable and, because of registerWhenStable, delays the worker's registration until the 30 seconds run out. Bookstore is zoneless (the default since Angular 21), so that risk does not apply, but waiting for whenStable() is correct in both cases.

Do not use activateUpdate() without reloading the page. Angular's own documentation warns about it too: activating the new version without a reload makes the old main.js load the new version's lazy chunk, the same "page v2 + chunk v3" mix as in the previous post. Show the user a button and let it do the reload.

unrecoverable: When a File of the Version Disappears

What happens if, while a tab still runs an old version, a file of that version is missing both from the worker's cache and from the server? The browser can partially delete the cache under disk pressure, and the new deploy has removed the old hashed files. To imitate this I deployed v3, then deleted the lazy chunks from the cache of the version the open tab was using and cleared Chromium's HTTP cache. Then I moved to the "My orders" page, which had not been loaded yet:

--- 4. a file of the running version disappears
--- deployed v3: ng build, old hashed files deleted
deleted 8 cached chunks of the running version (as if the browser had evicted them)
unrecoverable: Failed to retrieve hashed resource from the server. (AssetGroup: app | URL: /chunk-9r4Qn76r.js)
banner: Bookstore must reload to keep working. Reload
after reload: heading "Catalog (v3)"

The worker requested the chunk from the server, got a 404 and sent the unrecoverable event to the page; the shell showed the "Bookstore must reload to keep working" banner. The only remedy is a full reload; after it the page opened with v3. The most common cause of the unrecoverable state is deleting old files right away during a deploy. Keeping the old version's hashed files on the server for a few versions nearly removes this risk; since their names contain a hash, they do not collide. The same scenario ended with the same message in WebKit.

Deploying: Cache Headers and safety-worker.js

NGSW working correctly depends on the server's headers. If ngsw.json and index.html go stale in the HTTP cache, the worker cannot see the new version; hashed bundles never change, so they can be cached forever. The headers given by the mock API's --app mode:

/index.html              Cache-Control: no-cache
/books/9780441172719     Cache-Control: no-cache
/ngsw.json               Cache-Control: no-cache
/ngsw-worker.js          Cache-Control: no-cache
/main-UW6NLQP5.js        Cache-Control: public, max-age=31536000, immutable
/chunk-5Bq5Z2tF.js       Cache-Control: public, max-age=31536000, immutable

The code for the rule is small:

// 05: --app <folder> (or APP_DIST) serves the Angular production build instead of web/, with the cache headers a deploy needs
const appArg = process.argv.indexOf("--app");
const appFolder = appArg > 0 ? process.argv[appArg + 1] : process.env.APP_DIST;
const appDist = appFolder ? `${resolve(appFolder)}/` : null;

function cacheControlFor(pathname: string) {
  if (appDist && /-[\w-]{8}\.(js|css)$/.test(pathname)) return "public, max-age=31536000, immutable"; // hashed bundles never change
  // 04: the service worker script gets a long max-age, like a careless "cache all .js for an hour" rule
  if (pathname === "/sw/sw.js") return "max-age=3600";
  return "no-cache"; // index.html, ngsw.json, ngsw-worker.js, manifest: always ask the server
}

Writing the same rules in Nginx and IIS is covered in the Web Servers and Hosting series posts "Serving Angular with Nginx: SPA Fallback, Cache Headers, Compression and an /api Proxy" and "IIS in Production: Deploying, app_offline.htm, Locked Files, Application Initialization and Angular". Diagnosing a user who sees the old bundle after a deploy is covered in the Browser DevTools series post "The Application Panel: Storage, Cookie Flags, Cache Storage and the Angular Service Worker", where we read the output of the /ngsw/state page step by step.

If the worker itself causes a problem, Angular provides a ready-made kill switch: replace ngsw-worker.js with the safety-worker.js in dist (the same content also exists as worker-basic.min.js). This worker calls skipWaiting on install and clients.claim on activate, removes its registration and deletes every cache whose name starts with ngsw::

--- 5. safety worker deployed as ngsw-worker.js
1 s after the reload: registered
7 s after the reload: unregistered
first reload: controller yes, registrations 0, ngsw caches 0
second reload: controller yes, registrations 0, ngsw caches 0

Two observations. First, the safety worker did not take over at once: the registration went away after 7 seconds in Chromium and 6 in WebKit. While NGSW's background work runs (its idle task queue; in the source, a 5-second idle wait, at most 30 seconds), the old worker has to be released before the activation. Second, the registration and the NGSW caches are gone, but the page still has a controller: the app registers ngsw-worker.js again with provideServiceWorker on every load, and since that address now holds the safety worker, it gets installed, takes the page and removes itself again. The safety worker has no fetch handler, so requests go straight to the network and the app runs as if there were no worker. To end this loop, set provideServiceWorker to enabled: false or remove it in the next deploy, and keep the safety worker on the server until old users have come back (a few weeks).

In Production

Think of a deploy as atomic: first the new hashed files, then index.html, and ngsw.json last. If ngsw.json is published before the new files, the worker tries to download files that are not on the server yet, hash verification fails and the page gets VERSION_INSTALLATION_FAILED. Make deleting the old hashed files a separate, delayed step.

/ngsw/state is the fastest diagnostic tool for problems in the field: if the driver state is not NORMAL (for example EXISTING_CLIENTS_ONLY), the worker serves only existing tabs because of an error. Leave a way for the support team to ask the user for this page's output.

applicationMaxAge and a data group's maxAge are different things. The first is counted from build time and, once exceeded, sends every request to the network even if there is no newer version; offline support switches off at the end of that time. Think twice before setting it in an app that is not deployed for long periods.

Common Pitfalls

  • Trying to test the service worker under ng serve. Because of enabled: !isDevMode() it is not registered; serve the production build from an HTTP server.
  • Putting personal or fast-changing endpoints into dataGroups. NGSW ignores Vary; orders, the cart and the profile should stay network-only.
  • Giving ngsw.json or index.html a long max-age. The worker does not see the new version; both should be no-cache.
  • Deleting old hashed files immediately on deploy. Tabs running the old version cannot find lazy chunks and fall into the unrecoverable state.
  • Calling activateUpdate() on VERSION_READY without asking the user. The old shell mixes with new chunks; use a banner and a full reload.
  • Deploying the safety worker and leaving provideServiceWorker in the app. The app registers it again on every load; a harmless but pointless loop.

A Small Experiment

★☆☆ Switch the catalog data group to the performance strategy with maxAge: "1m". After opening the catalog, change Dune's price in the mock API and reload the page within a minute and after a minute; compare which price you see and how many requests appear in the server log.

★★☆ Add logic to AppUpdates that calls checkForUpdate() when the tab becomes visible again (visibilitychange), but not if the last check was less than 10 minutes ago. In Playwright, hide and show the page and count how many checks are made.

★★★ Write a script that imitates a deploy: build into a separate folder, add the hashed files to the existing dist (without deleting the old ones), then copy index.html and, last, ngsw.json. Repeat step 4 of this post and confirm that the unrecoverable event no longer arrives.

Bookstore now opens offline and tells the user about new versions, but it is still a browser tab. In the next post we turn it into an installable app: the fields of the manifest ng add generated, maskable icons, beforeinstallprompt and the new navigator.install() in Chromium, "Add to Home Screen" on iOS, and App Badging for unread orders.