ADR-029 — Service worker via @vite-pwa/sveltekit
Status · Accepted Date · 2026-05-02 Closes · Theme C.C1 from issue #18 (deferred in v0.1.10) TA anchor · §components/build · §components/static-deploy Related ADRs · ADR-014 (GitHub Pages CI + preview), ADR-016 (build-time external assets), ADR-021 (docs site at /docs/), ADR-025 (a11y tier-1 contract)
Context
v0.1.10 shipped a basic PWA manifest (static/manifest.webmanifest) but deferred the service worker. The deferral note in the v0.1.10 commit cited two concerns: (1) GitHub Pages base-path interaction with the SW scope, (2) the new dev-dep would need its own ADR.
This ADR addresses both. With the manifest in place users can already "Add to Home Screen" on Android; iOS additionally requires a registered SW for proper install behaviour. Offline-first behaviour for cold starts works through the browser's HTTP cache, but a SW lets us:
- Define explicit cache strategies per asset class (shell + textures cached aggressively; data files network-first with cache fallback so v0.1.x updates surface promptly).
- Provide the offline-by-default UX iOS users expect from an installed app.
- Pre-cache critical routes on install so first offline visit doesn't 404.
Decision
Plugin choice — @vite-pwa/sveltekit
Use the official @vite-pwa/sveltekit integration (Vite ecosystem, maintained by the Vite PWA project). Adds a single devDependency. Generates the SW from a Workbox config in vite.config.ts and integrates with the existing @sveltejs/adapter-static build chain.
Rejected: hand-rolled SW (more control but more maintenance; Workbox handles cache-busting, navigation fallback, and update-flow correctly out of the box).
Cache strategy
- Precache (cache-first, immutable): app shell HTML/JS/CSS, fonts (
static/fonts/*), texture atlases (static/textures/*), agency logos (static/logos/*.svg), planet/sun/earth-object/moon-site images (everything understatic/images/). - Runtime cache (stale-while-revalidate): mission JSON files (
static/data/missions/**/*.json) and i18n overlays. Users get the cached copy instantly while a fresh fetch updates the cache for next visit. - Network-first (fall back to cache): the
static/data/mission-galleries.json+ sibling manifests. These rarely change but new fetch-assets runs may add entries. - Excluded from cache: porkchop grid JSONs (
static/data/porkchop/*.json) — large (~110 KB each) and accessed only on the /plan screen; let the browser HTTP-cache them on demand.
Base-path handling
The SW scope must match the deployed base path (/orrery/ on GitHub Pages, / on local preview). @vite-pwa/sveltekit reads process.env.VITE_BASE (already set by svelte.config.js) so the plugin's manifest.scope and start_url are computed correctly per-environment. Verified by the existing svelte.config.js:17 pattern.
Install-prompt UX
Show the install prompt only after the user has visited 3 or more screens. This avoids the "install this!" pop-up on first paint that drives bounce. Visit counter held in runtime memory only (per CLAUDE.md "do not use localStorage"). Cleared on tab close — acceptable since the SW itself persists across sessions.
Implementation: src/routes/+layout.svelte increments a counter on each route change; beforeinstallprompt event handler defers the call to event.prompt() until the counter ≥ 3.
iOS PWA caveats
iOS Safari ignores beforeinstallprompt. iOS users get a manual "Add to Home Screen" via the share sheet. The manifest's display: standalone + apple-mobile-web-app-capable meta tag still produce the correct in-app shell. Documented in README under "Install as app".
CI verification
CI runs npm run build and verifies the SW file lands in build/ (a single grep -q service-worker.js build/ check added to .github/workflows/ci.yml). Lighthouse CI gate for PWA score deferred to a v0.1.12 follow-up since it needs a separate lhci config.
Update flow
Users on a stale shell get a "New version available — refresh?" toast when the SW detects a new build. Implementation: vite-pwa/sveltekit's built-in useRegisterSW composable, surfaced in +layout.svelte. Reduced-motion users get the same toast without the slide-in animation (gated by the global prefers-reduced-motion: reduce rule).
Rationale
@vite-pwa/sveltekit is the path of least resistance: officially supported, maintained, integrates with adapter-static + dynamic base path, and handles Workbox config without hand-rolled boilerplate. The cache strategies match the natural data-vs-shell split already implicit in the repo structure (everything under static/ is build-time-fixed; mission JSON evolves between releases).
The visit-counter deferral pattern matches the spirit of CLAUDE.md's "no surprises" rule — install prompts are an interruption; deferring until the user has demonstrated engagement (3+ screens) lets the prompt feel earned.
Alternatives considered
- Hand-rolled SW + manual cache management — more flexibility but more maintenance. Rejected because Workbox already handles the 95% case correctly.
- Workbox CLI standalone — ships an SW but needs a separate build step outside Vite. Rejected because
@vite-pwa/sveltekitintegrates with the existing pipeline. - No SW, ship manifest only — what v0.1.10 did. Works on Android but iOS install behaviour is degraded. Rejected because the additional install-friction on iOS hurts the "browser-native, no app store" promise.
Consequences
Positive:
- Orrery becomes properly installable on iOS + Android with offline-first behaviour after first visit.
- Cache strategies keep shell + assets fast while letting data updates surface.
- Update toast gives users explicit control over when to pick up new builds.
- Visit-counter deferral keeps the install prompt from being intrusive.
Negative:
- New devDependency:
@vite-pwa/sveltekit(~2 MB install size; pulls inworkbox-*packages). Acceptable per ADR-016's external-asset budget. - SW debugging adds a small operational cost: stale caches require devtools "Update on reload" or unregister to test fresh.
- GitHub Pages base-path edge cases need verification before tagging — covered by the build-step
grepcheck in CI. - iOS users still don't get
beforeinstallprompt; their install path stays manual via the share sheet.
Implementation notes
vite.config.tsgains aSvelteKitPWAplugin block withregisterType: 'prompt',manifest: false(manifest is already atstatic/manifest.webmanifest), and aworkboxconfig definingglobPatterns+runtimeCaching.src/routes/+layout.sveltegains a smalluseRegisterSW-driven update toast + visit-counter logic forbeforeinstallprompt.package.jsonadds@vite-pwa/sveltekitto devDependencies.- README "Install as app" section explains both Android (auto-prompt) and iOS (share sheet) paths.
Issue tracking: closes Theme C.C1 from #18. C2 (manual contrast toggle) and C3 (Lighthouse gate) follow in the same v0.1.12 milestone but are tracked separately in this plan.