Telling an Installed PWA That a New Version Shipped
Sep 10, 2026
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()plusfocusandvisibilitychangetriggers - A
build_allstep that keepsngsw.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.
