This site is an installable PWA. That's a nice feature and a genuinely annoying one: once someone adds it to their home screen, the Angular service worker caches the whole app shell and serves it instantly on every launch — including, by default, forever, or at least until every tab and window for the origin has been closed and reopened. On a phone, where the OS silently kills backgrounded apps and "reopening" is a cold start against the cache, that means a user can run a build from three deploys ago indefinitely and have no way of knowing.

So I wanted two things: a version number the user can actually see, and a small nudge when a newer one is ready to activate. Neither is much code. Getting the nudge to actually fire on a real installed PWA took four separate fixes, and every one of them was invisible until the app was running on a phone with a debugger attached.

The visible version

A tiny script, scripts/generate-build-info.mjs, reads the version field out of package.json and writes it — with a build timestamp — to a gitignored src/app/build-info.ts:

export const BUILD_INFO = {
    version: '0.0.18',
    builtAt: '2026-09-09T16:56:15.102Z',
};

The root component imports that and renders it in the page footer: v0.0.18 · built Sep 9, 2026, 11:56:15 PM. The script runs from prestart, prebuild, prewatch and pretest npm hooks so it's always current in dev and CI. The deploy script (build_all) calls ng build directly, which bypasses the npm hook, so it runs the generator itself as its first step.

This isn't glamorous, but it's the thing that makes every future bug report tractable: "what does the footer say?" immediately tells me whether someone is even on the build I think they're on. It's also the target the update banner gets tested against — bump the number, deploy, watch for the banner.

The banner

Ported from another project of mine. AppUpdateService listens to Angular's SwUpdate.versionUpdates stream, filters for VERSION_READY (the event that means "a new version is fully downloaded and sitting in the cache, ready to activate"), and flips a signal:

this.swUpdate.versionUpdates
    .pipe(filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'))
    .subscribe(() => this.updateAvailable.set(true));

A presentational UpdateBanner component renders a fixed bar at the bottom of the viewport when that signal is true, with a Refresh button that calls swUpdate.activateUpdate() and then reloads the page. That's the entire user-facing feature.

And it didn't work. The banner never showed up in installed-PWA mode on mobile — which is the only mode where it matters.

Four bugs, none of them in the banner

1. The service worker never registered in time

Angular's default registration strategy is registerWhenStable:30000: wait until the app has no pending macro/microtasks, then register, or give up waiting after 30 seconds. But this app's root ngOnInit always has an HTTP request in flight (whoami(), sometimes getTags() too). On a fast connection that resolves quickly and stability is reached. On a slow one — or in a mobile PWA session that the OS kills after a few seconds — the app never reaches "stable," so the service worker registration itself never runs. No registration, no update checks, ever.

Fix: registrationStrategy: 'registerImmediately'. Against a real production build served statically, the SW registered ~95ms after navigation instead of never.

2. The update check window was never entered

AppUpdateService originally only called checkForUpdate() on a 6-hour interval, or when the page went from hidden to visible. Both assumptions are true of a desktop browser tab, which often lives in the background for hours. Neither is true of an installed PWA on a phone: it gets fully killed when backgrounded, so reopening it is a cold start with the tab already visible when the listener attaches (no hidden→visible transition to catch), and a session basically never stays open long enough to hit a 6-hour timer.

Fix: call checkForUpdate() once immediately on boot. And add focus as a second trigger alongside visibilitychange, because Android WebAPK/Chrome doesn't reliably fire a visibilitychange transition when you resume the app from the task switcher, but window focus has proven more consistent there.

3. The service worker was silently rejecting every update

This is the one that took a real debugger. With registration and the boot-time check both fixed, checkForUpdate() was running, resolving cleanly — and no VERSION_READY ever arrived.

build_all does a post-processing pass after ng build: a sed rewrite that appends ?v=<content-hash> to the icon URLs in index.html and manifest.webmanifest, so that Android's WebAPK installer — which only refreshes the home-screen icon when the manifest's icon URL changes, not when the bytes behind a stable URL change — always sees a new URL after an icon change.

The problem: Angular's CLI computes ngsw.json's hash table (the service worker's integrity manifest) during ng build, from the original bytes of those two files. The sed pass then changes those bytes. So from that point on, index.html and manifest.webmanifest as served never matched the SHA-1 the service worker expected. The SW would download the new version, run its integrity check, find the mismatch, and abort with VERSION_INSTALLATION_FAILED: Hash mismatch — permanently, every time. And checkForUpdate() still resolves without throwing when that happens, so from the app's point of view everything looked fine.

I only found it by attaching chrome://inspect to the phone, dropping temporary console.log calls into every step of AppUpdateService, and watching the actual versionUpdates events come through as VERSION_INSTALLATION_FAILED instead of VERSION_READY.

Fix: build_all now recomputes the SHA-1 of index.html and manifest.webmanifest after the sed pass and patches the new hashes straight into ngsw.json's hashTable with a small node -e snippet. If the icon-stamping step or ngsw-config.json ever changes, this needs re-verifying — the failure mode produces no error anywhere until a real device tries to install the build.

4. Caching headers on the manifest files

For the update check to even get a chance, the service worker has to be able to fetch a fresh ngsw.json and index.html — if the CDN or browser hands it a cached copy, it compares the new build against itself and sees no change. The Apache vhost sends Cache-Control: no-cache for index.html, index.csr.html, manifest.webmanifest, ngsw.json and the worker scripts, and Cache-Control: public, max-age=31536000, immutable for the content-hashed main-XXXXXXXX.js / styles-XXXXXXXX.css bundles (whose names change when their content does, so they're safe to pin forever).

The workflow that falls out of this

Every deploy bumps the patch version in package.json (npm version 0.0.x --no-git-tag-version, committed) before running build_all. That guarantees there's always a new version string for the footer and always something for the service worker to detect. The commit history is full of Bump version to 0.0.N to test <thing> — that's the feedback loop: each real deploy re-exercises the whole update path, and the footer plus the banner tell me within one app launch whether it still works.

Testing

AppUpdateService has unit tests that stub SwUpdate and override the reload() call. They're worth having. But they could not have caught a single one of the four bugs above — registration timing, the missing boot-time check, the hash mismatch, and the cache headers are all things that only exist once the app is built, deployed, installed on a phone, and inspected live. This is the same lesson as going zoneless: a green suite proves the code does what the tests describe, not that the tests describe what actually happens in the field.

Where this landed

  • A build version + timestamp in the footer, regenerated on every build/start/test from package.json
  • A VERSION_READY-driven refresh banner, ~15 lines of app code
  • registrationStrategy: 'registerImmediately' so short mobile sessions actually register the worker
  • A boot-time checkForUpdate() plus focus and visibilitychange triggers
  • A build_all step that keeps ngsw.json's hash table honest after the icon-URL rewrite
  • Cache-Control headers that let the worker see fresh manifests

The user-facing part is trivial. The reason it took days of version-bump commits is that a service worker update can fail at registration, at the check, at download, at the integrity check, or at activation — and it resolves cleanly at every one of those stages. You cannot debug it from "did the banner show?" You have to watch the events.