PWA and delivery
This is a guide, not the contract. What the platform guarantees is specified under
openspec/specs/. For this page:platform-composition. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.
PWA is the default, not a requirement. The whole decision is one option:
provideShell() ships it on (installable app, offline shell, the update badge/toast), and
provideShell({ serviceWorker: false }) turns it off. Turn it off whenever your build does not emit
a worker. The scaffolded quick start wires the PWA side for you, and the
manual setup deliberately starts without it.
provideShell() registers the service worker itself, inert in dev, so do not add
provideServiceWorker to your own providers. That is why @angular/service-worker is a peer
dependency rather than an optional extra: the shell imports it, so your build needs it installed
whether or not you ship a worker.
What you supply is the build side, and only the first line is required for the update flow:
| Where | Needed for | |
|---|---|---|
ngsw-config.json + serviceWorker in the build target |
project root · angular.json or project.json |
the worker exists at all — without it, registration 404s |
manifest.webmanifest + icons |
public/ |
installability (home screen, standalone window) |
Build with the production configuration to exercise either; the update badge and toast read one signal, so the version/update chrome works with no wiring.
Two of those details decide whether the app is really installable and really offline, and neither one fails a build.
Icons. A manifest without an icons entry cannot be installed at all, and Chromium only offers
installation once the manifest names a 192 and a 512 raster icon; an SVG is fine for the browser
tab and is what the scaffold points at, but it does not satisfy that check, and iOS ignores manifest
icons entirely in favour of an apple-touch-icon link. So ship icon-192.png and icon-512.png,
name them in the manifest, and add the apple-touch-icon link. Keep purpose: "any" and
purpose: "maskable" on separate files. A maskable icon is cropped to a circle or a squircle
and needs padding that an edge-to-edge mark does not have, so one file cannot be correct as both.
Translations. The shell fetches its UI strings at runtime, so an asset group that does not cover them produces an app that installs, opens offline and renders every label as its raw translation key. Nothing errors. Cache them explicitly:
// ngsw-config.json — alongside the "app" and "assets" groups{ "name": "i18n", "installMode": "prefetch", "updateMode": "prefetch", "resources": { "files": ["/i18n/**/*.json"] }}No worker at all? Pass provideShell({ serviceWorker: false }). Registration is skipped,
UpdateService.enabled reports false and no update is ever offered. The service injects
SwUpdate optionally, so nothing else changes. Use it whenever your build emits no
ngsw-worker.js; otherwise production logs a failed registration for a file that was never built.
The update chrome is honest about failures and long-lived sessions. A failed installation
(VERSION_INSTALLATION_FAILED, e.g. a hash mismatch after a broken deploy) raises a sticky
“update failed” toast and flips the badge to a caution state instead of claiming the app is
current, and the shell checks for updates in the background (every 30 minutes and whenever the tab
becomes visible again), so a tab that stays open for days still learns about a deploy without a
navigation.
The two ways an update goes wrong are told apart, because only one of them a reload can fix:
| State | What it means | What the affordance does |
|---|---|---|
UpdateService.updateFailed |
An update could not be installed. The worker is healthy and the client keeps running its current version. | Reloads, which retries the install. |
UpdateService.updateBroken |
The worker reported an unrecoverable state: its cached asset table no longer matches what the server serves. | Unregisters the worker, drops its caches, then reloads into a fresh registration. |
The distinction matters because a plain reload cannot leave the second state. The broken registration
would still control the next load and report the same failure, which is a loop the user cannot escape
from inside the app; they would have to know to clear the site’s browser storage. A deploy that
removes the previous build’s hashed files (anything using rsync --delete) is enough to put a client
there. Only the shell’s own ngsw-worker.js and its ngsw: caches are touched, so a worker or cache
your product registered itself is left alone. Read updateBroken only if you want to word it
differently in your own UI; the built-in toast and badge already do.
Validate the service worker against a build, never against the dev server. The Angular dev
server transforms files per request. The bytes it serves therefore do not match the hashes in the
ngsw.json it emits from the same build. The worker verifies every asset against that manifest. So
serving the production configuration through ng serve makes each watch-mode rebuild end in
VERSION_INSTALLATION_FAILED, which raises the sticky “update failed” toast permanently, from a
perfectly healthy build. That failure is indistinguishable from a broken deploy by design (the client only
sees a hash mismatch), so do not paper over it in the update chrome: run the dev server with the
development configuration, where the shell never registers a worker, and exercise the PWA and
update flow against a served build instead. The testbed and the demo both work this way: one script
serves the development configuration, another serves a production build. Two details of that
pattern are worth copying rather than rediscovering.
Give the preview its own port. A worker’s scope is the origin, so a registration a preview leaves behind keeps controlling the dev server on the same port and serves you the stale cached build instead of your edits.
Serve the preview over plain HTTP on 127.0.0.1, not over HTTPS with a self-signed certificate.
Browsers already count loopback as a secure context, so the worker registers with no certificate at
all. A self-signed one is worse than none here: the page loads, isSecureContext is true, and the
worker script fetch still fails the certificate check with “An SSL certificate error occurred when
fetching the script”. Nothing registers, and the preview silently stops previewing the one thing it
exists for.
Where next
- Windows, sync and updates:
UpdateService, the version and update flow from your own code. - Branding: the identity the manifest and the About dialog read.
- Manual setup: the setup that deliberately starts without a worker.