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.

  1. 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.

  2. registerType: 'prompt'. No automatic skipWaiting/clientsClaim. A new build installs into the waiting state and activates only on explicit operator action (reload), surfaced via onNeedRefresh.

  3. The service worker MUST NOT intercept or cache any request matching /api/.*. No precache entry and no runtimeCaching route may match it; navigateFallbackDenylist excludes /api/.* so the SPA navigation fallback never swallows an API call. API traffic always goes to network.

  4. Version generation (build time): inject a build identifier (git short SHA + ISO build timestamp) as a compile-time constant, and emit version.json to the output root with the same payload. version.json is excluded from the precache glob so it is always fetched fresh.

  5. Version check (runtime): a best-effort probe fetches version.json with cache: 'no-store' on focus/visibility regain and on a low-frequency interval. On a mismatch with the baked-in constant it calls registration.update() to kick the SW update cycle (which then reaches onNeedRefresh → 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.

  6. Host headers — Cloudflare Pages specifics. Pages serves all assets with public, max-age=0, must-revalidate by default, so index.html, version.json, and sw.js are already revalidated on every load — no _headers override is needed to keep them fresh, and the browser HTTP cache cannot serve a stale index.html. The _headers file's actual job here is the inverse: mark fingerprinted assets (/assets/*) public, max-age=31536000, immutable so 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 revisions index.html and 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 generateSW as 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 or caches.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 generateSW without switching to injectManifest. 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 when activeRuns == 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 define build constant __APP_VERSION__ = <gitSha>+<builtAt>; emit version.json with the same payload to the output root; exclude version.json from the precache glob.
  • Runtime version probe: fetch('/version.json', { cache: 'no-store' }) on visibilitychange→visible and on interval; on mismatch call registration.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's updateSW().
  • ⛔ 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 for index.html/version.json/sw.js — Pages serves max-age=0, must-revalidate by 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).