ADR-0046: PWA Offline Strategy — Precache UI Shell, Exclude API, Operator-Gated Updates
Status
Accepted — 2026-06-19.
Context
The UI bundle has grown. Most of the time large transfers are fine, but under poor reception the cost is real. The UI (Vite + React SPA, TanStack Router lazy routes, separately hosted) and the daemon API are decoupled and communicate over bearer tokens. We want:
- The UI to load and run offline / when the UI host is unreachable.
- The API to remain live at all times — never served from cache. A stale orchestration state is a correctness hazard and the daemon is the source of truth.
- Releases to invalidate cleanly. The previous hand-rolled SW used manually-named caches and cache-first on un-revisioned URLs; purging stale caches on each release was a recurring pain.
- Updates to never interrupt a running session unexpectedly.
- A version check that is best-effort: if it can't reach the host (bad reception), the operator keeps running their current cached build with no degradation.
A web app manifest provides installability only; it performs no caching. Offline behavior requires a service worker.
Decision
Adopt vite-plugin-pwa (Workbox generateSW) with the following configuration and supporting build/runtime pieces.
Precache the content-hashed build output (JS/CSS/HTML/fonts/icons) — full shell precache;
navigateFallback: '/index.html'so deep links resolve offline. Vite's content hashing means the precache manifest is revisioned per file.registerType: 'prompt'. No automaticskipWaiting/clientsClaim. A new build installs into the waiting state and activates only on explicit operator action (reload), surfaced viaonNeedRefresh.⛔ The service worker MUST NOT intercept or cache any request matching
/api/.*. No precache entry and noruntimeCachingroute may match it;navigateFallbackDenylistexcludes/api/.*so the SPA navigation fallback never swallows an API call. API traffic always goes to network.Version generation (build time): inject a build identifier (git short SHA + ISO build timestamp) as a compile-time constant, and emit
version.jsonto the output root with the same payload.version.jsonis excluded from the precache glob so it is always fetched fresh.Version check (runtime): a best-effort probe fetches
version.jsonwithcache: 'no-store'on focus/visibility regain and on a low-frequency interval. On a mismatch with the baked-in constant it callsregistration.update()to kick the SW update cycle (which then reachesonNeedRefresh→ prompt). On any fetch failure the probe swallows the error silently and retains the running build. The probe never blocks boot and never degrades the running app. The probe kicks the SW cycle — it is not a second, parallel update mechanism.Host headers — Cloudflare Pages specifics. Pages serves all assets with
public, max-age=0, must-revalidateby default, soindex.html,version.json, andsw.jsare already revalidated on every load — no_headersoverride is needed to keep them fresh, and the browser HTTP cache cannot serve a staleindex.html. The_headersfile's actual job here is the inverse: mark fingerprinted assets (/assets/*)public, max-age=31536000, immutableso the browser stops needlessly revalidating files that never change. Note: a "stale index → purged chunks" blank shell can only originate from the SW precache (Cache API), which no host header or Pages deploy can reach; Workbox revisionsindex.htmland its chunks as one set, so that layer stays stale-but-consistent until the operator reloads. It is governed solely by the SW update cycle, never by Pages.
Consequences
Commits us to
- Workbox
generateSWas the SW generator; the precache manifest is the single source of truth for offline assets. - The SW update lifecycle (waiting → operator reload) as the only path by which new UI code activates.
Forecloses
- Caching API responses for offline read. Deliberately excluded — the daemon is authoritative; stale run/session state must never be served. Offline data for the API, if ever wanted, is a separate ADR.
- Auto-activating updates mid-session (no
autoUpdate). See Alternatives.
Makes easier
- Releases self-invalidate: only changed hashed files re-download; old precache is purged on
activate. No manual cache-name bumps orcaches.delete(). - "UI host down, keep working": the shell boots from precache and talks to the live daemon directly. The decoupling becomes a resilience asset.
- Showing the current build version in-UI (the baked constant) and an "update available" affordance.
Makes harder
- In-SW logic (background sync, push handling) is unavailable under
generateSWwithout switching toinjectManifest. Escape hatch noted below. - Operators on a stale build won't auto-upgrade; they must reload when prompted. Intended (operator agency), but means the prompt must be visible.
Alternatives considered
- Manifest only / no service worker. Rejected: no offline capability at all.
- Hand-rolled SW with manual cache names (prior approach). Rejected: this is the release-purge pain being removed.
registerType: 'autoUpdate'(skipWaiting + clientsClaim). Rejected as default: swapping the SW mid-session can orphan lazy route chunks (chunk-load errors) and interrupt a live run. Possible future refinement: auto-apply a waiting SW only whenactiveRuns == 0(mirrors the daemon reload-when-idle gate), otherwise prompt. Tracked, not adopted now.injectManifest(custom SW source). Deferred: more surface than needed today. Adopt only if in-SW logic is required (e.g. background sync of queued actions). The precache/version/exclusion decisions here carry over unchanged.- Caching API GETs with a short TTL for read resilience. Rejected: orchestration state staleness is a correctness hazard; the offline value comes from the shell, not from stale data.
Implementation checklist
- Add
vite-plugin-pwa;generateSW,registerType: 'prompt', precache glob covering build output,navigateFallback: '/index.html'. - ⛔
navigateFallbackDenylist: [/^\/api\//]; assert no registered route matches a sample/api/...URL (test). - Vite
definebuild constant__APP_VERSION__=<gitSha>+<builtAt>; emitversion.jsonwith the same payload to the output root; excludeversion.jsonfrom the precache glob. - Runtime version probe:
fetch('/version.json', { cache: 'no-store' })onvisibilitychange→visible and on interval; on mismatch callregistration.update(). ⛔ On failure, swallow and retain the current build (test: simulated network failure → app continues, version unchanged). -
registerSW({ onNeedRefresh })wired to an operator-visible "new version — reload" affordance; reload calls the plugin'supdateSW(). - ⛔ No auto skipWaiting: assert a waiting SW does not activate without explicit
updateSW()(test). -
_headers:/assets/*→public, max-age=31536000, immutable. No freshness override needed forindex.html/version.json/sw.js— Pages servesmax-age=0, must-revalidateby default. - Verify offline boot: with the UI host unreachable, the installed app loads the shell from precache and reaches the live daemon.
Out of scope / related
- UI ↔ daemon version compatibility handshake (separate concern; potential future ADR).
- Background sync / queued offline actions (would motivate
injectManifest).