Ultimate

PWA and offline

✅ WIRED END TO END, As of 2026-08

x dev, the container and x build --target static all emit manifest.webmanifest, sw.js and x-sw-register.js, plus the <head> that names all three. The manifest reader is packages/cli/src/pwa-artifacts.ts and the worker's is packages/cli/src/sw-artifacts.ts.

This box said the opposite until 2026-08-27 — generateServiceWorker, buildPrecacheManifest, offlineFallbackSource, backgroundSyncSource and pushSource had zero callers outside @ultimat3/pwa itself, so pwa.offline, pwa.backgroundSync, pwa.push and every route's own offline: field were declarations with no build behind them (#390, #362).

What unblocked it was the browser check, not the emitter. A bad sw.js is sticky — a manifest a browser dislikes is ignored, a worker that caches wrong keeps serving wrong bytes until the user clears site data — so the rule was: no worker until something can drive a real one. packages/cli/e2e/service-worker.e2e.test.ts is that check. It registers the emitted file in a real Chrome, waits for it to take control, takes the network away, and asserts that a runtime route with nothing cached renders the offline document. It runs on every push.

pwa.offline is now a block, not a string — { fallback, image, font, neverCache }, and fallback is required once enabled is true. See Upgrading for the one-line edit; the old offline: 'runtime' was an app-wide default for a field defineRoute makes required on every route, so it defaulted nothing and was read by nobody.

pwa.push is wired too, As of 26.1.0 (#710): a pwa.vapid key, a VAPID pair the boot resolves (or refuses), the worker's push handler, a subscription action pair and a notify channel — Push.

sw.js is a build artifact, generated from the route table and written by x build. Hand-editing one is not caught, and it does not survive either: As of 2026-08 sw.js carries no checksum, so the next build overwrites the edit in silence — no warning, no diff, nothing in --json. X_SW_HAND_EDITED is a reserved name — nothing raises it, and x errors explain X_SW_HAND_EDITED refuses it (Error codes → Not thrown yet).

As of 2026-08. Stable API — semver from here (Upgrading).

Why generated

A service worker is a cache-policy compiler whose input is already declared on every route: render mode, offline strategy, asset graph. Hand-writing it duplicates that information, and the duplicate drifts. Every notorious PWA bug — the page serving last month's HTML, the chunk 404 after deploy, the user stuck on a version until they clear site data — is a service worker that disagreed with the app.

The edit an agent should make is the route's offline field, and x build reads it, As of 2026-08. Hand-editing the emitted sw.js is still not caught, per the reserved-code note above.

Derived from the route

export const config = defineRoute({
  render:     'isr',                  // static | isr | ssr | stream
  revalidate: { tags: [tag.post] },
  prerender:  () => db.posts.slugs(),
  offline:    'precache',             // precache | runtime | network-only
  hydrate:    'visible',              // idle | visible | interaction | never
  budget:     { js: '40kb' },
  meta:       ({ post }) => ({ title: post.title, description: post.excerpt,
                               og: { image: post.cover }, ld: ld.Article(post) }),
});

Precache set

IncludedSource
Every route with offline: 'precache'route table + its prerender() URLs
The island chunks those routes boot, every surface stylesheet, the framework scriptsrendered documents / declared islands, As of 22.3.3
Fonts, icons, and priority images they referenceasset graph
The offline fallback routerequired (see below)

Excluded always: api/ responses and every personal route (a policy, a stream, a no-store/private cache) — even with offline: 'precache', As of 22.3.3.

Total precache size is a warning, not a budget, As of 2026-08: buildPrecacheManifest puts DEFAULT_PRECACHE_WARN_BYTES overruns on PrecacheManifest.warnings, and nothing reads that field — no x verify step loads a precache manifest, because nothing builds one. Designed as a budget; not one today.

Runtime strategy from render mode

MODE_STRATEGY in packages/pwa/src/strategies.ts, read by strategyFor() and keyed on Record<RenderMode, StrategyName> — a mode with no row and a row for a mode that does not exist are both compile errors, As of 2026-08.

renderStrategyRationale
staticnetwork-firstthe document names this deploy's hashed assets; the precached copy is the offline answer
isrnetwork-firstthe server's ISR cache is already stale-while-revalidate; a second, older copy in the browser only delays a deploy
ssrnetwork-firstcaching a per-request render is a correctness bug; the offline fallback answers on failure
streamnetwork-firstthe shell names this deploy's chunks; the cached copy is the offline answer

Every document is network-first, As of 22.3.2. Every rule the worker routes is a document — content-hashed chunks get none and live in the browser's HTTP cache (immutable). Until 22.3.2 static was cache-first and isr/stream stale-while-revalidate, so an online navigation got the HTML the old worker held, naming the old deploy's CSS and islands, until the visitor pressed Shift+F5. offline: 'precache' still precaches the document: precache decides what answers offline, not what answers while the network does. A per-route strategy can still ask for cache-first or stale-while-revalidate.

Navigation preload, As of 2026-09: on activate the worker enables registration.navigationPreload, so the browser sends a navigation's request while the worker is still starting, and every strategy answers from event.preloadResponse before it would fetch. Worker start-up is off the critical path of every navigation; a browser without the API, or a preload that resolves empty, is the worker's own fetch as before. The preload is the browser's request and carries no X-Ultimate-Build, so skew is read off the answer: another build's document posts AppUpdateAvailable and the worker stops stamping its id — what a 409 did, in one round trip.

The install fill is throttled, As of 2026-09: PRECACHE_CONCURRENCY (4) entries in flight, never every entry at once — the fill runs on the first visit, on the connection the first click needs. An entry whose answer carries another build's X-Ultimate-Build is not stored, As of 2026-10-02: mid-rollout an old pod's document was the new worker's offline copy. An unstamped answer (a static host) is stored; a skipped entry is fetched when asked for.

A rule matches the pathname the browser sends, As of 2026-10-02: a literal segment is percent-encoded the way url.pathname spells it (/precios-españa, /a b were never handled), and a catch-all owns its bare prefix — /docs/*path matches /docs, which the static export writes.

A new worker takes over at once, As of 22.3.2: the install block calls self.skipWaiting(), so a deploy's worker activates when its precache is filled rather than when every tab of the origin has closed. It never reloads a page — the open tab keeps what it has, gets AppUpdateAvailable, and its next navigation is answered with the new HTML. x-sw-register.js also calls registration.update() when a hidden tab becomes visible again, at most once per five minutes, so a tab left open for days finds the new worker before its next click. A browser still running a 22.3.1 worker converges with no user action: its next navigation fetches the new sw.js, which skips waiting and claims the tab — that one navigation is still the old worker's cached copy, and the one after it is fresh.

immutable is for a content-hashed URL only, As of 22.3.2. /x-sw-register.js (was max-age=3600) and the /icons/* matrix (was immutable, under names that are sizes, not bytes) answer public, max-age=0, must-revalidate with a strong ETag, and a 304 when unchanged — so a new register script or a new icon.png reaches every client on its next load. sw.js stays private, max-age=0; manifest.webmanifest and /favicon.ico keep their hour.

There is no per-mode offline default — offline is required by defineRoute's type and again at runtime (X_ROUTE_OFFLINE_MISSING). It is read before the mode: offline: 'network-only' is a declaration that this URL is never answered from a cache, and strategyFor returns network-only without consulting the table. A per-route strategy overrides both.

Overriding offline is allowed. Contradictions are not rejected As of 2026-08: offline: 'precache' on a render: 'ssr' route is accepted, and X_SW_UNCACHEABLE is a reserved name nothing raises (Error codes → Not thrown yet). The scope half is enforced — X_SW_SCOPE_INVALID, when the service-worker scope cannot serve the routes it precaches. Until the coherence check ships, review the pairing yourself: a per-request render has no cacheable body, so precache on ssr means the shell is served stale.

A page rendered for someone is never cached, As of 22.3.3 (a security fix). A route is personal when it has a policy, is render: 'stream', or declares cache: 'no-store' / { mode: 'no-store' | 'private' } (RouteDescriptor.personal). Its rule is network-only in every locale and it is never precached, whatever its offline says; a per-route strategy is still the override. And whatever the rule, the worker stores no response that is cache-control: private or no-store, or carries x-ultimate-scope, in any cache. From 21.0.0 to 22.3.2 such a response was kept in a per-member partition for offline, so after sign-out an offline navigation on a shared device showed the previous member's data. Offline, a personal page is the offline document.

An offline-first app can take 21.0.0's mode back, explicitly: pwa.offline.personalPages: 'last-member' routes a personal page by its render mode again and keeps the most recent member's own copy in a per-principal partition that only the offline path reads (never answered online, never precached — one declaring offline: 'precache' lands in the partition too, As of 2026-10-02; it got a precache rule and was never kept). Declare it only with a sign-out that clears it — clear-pages below, or signOutHeaders()' Clear-Site-Data. examples/dummy does, for its offline feed.

MessageSent byWorker does
{ type: 'clear-pages' }the page, on sign-outdeletes every build's pages cache (x-pages-*, and any 22.3.2 partition), then answers the posting window { type: 'pages-cleared' }
// sign-out, before navigating away — CLEAR_PAGES_MESSAGE from @ultimat3/pwa
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-pages' });

The precache is untouched: marketing pages still answer offline. Clear-Site-Data: "storage" on the sign-out response empties Cache Storage too, and is the belt to this message's braces.

The English site is offline too, As of 22.3.3. The server strips a non-default locale's prefix before it matches (/en/precios is /precios in English), and the worker matches pathnames, so it now gets every route once per routed locale — same strategy, same cache, its own precache revision. An offline navigation under /en/ gets /en/offline when that is precached, else the default one.

A client-router visit is a navigation, As of 2026-10-05. A navigation.client link fetches its page (x-ultimate-navigation: soft), which is no browser navigation, so offline it got an empty 503 and the tab landed on a blob: URL. The worker now answers it with the offline document; a prefetch still gets the 503, so the offline page is never cached as a later click's answer.

Islands are precached only for precached pages, As of 22.3.3. Every island chunk was precached, so a first anonymous visit downloaded every island the app has (~1 MB on one adopter). Now a chunk is precached only when a precached page (or the offline document) boots it — the static export reads the rendered documents, the container each route's declared island()s. Every other chunk is cached the first time a page asks for it: /islands/ is a cache-first runtime rule.

The installing page is cached too, As of 2026-09-22 (21.0.0). The page that registers the worker loaded before the worker controlled it, so no strategy saw it, and an offline: 'runtime' route was unavailable offline until a second online visit. On activate, the worker now runs each open window's URL through its route's own strategy, the same rule every later visit follows. It is best effort: a failure costs the warm-up only.

Mutations are never cached. Offline writes go through the tier-3 mutator queue (Realtime), not through Background Sync guesswork — a durable queue with a declared conflict strategy per mutator, replayed on reconnect and rebased against server truth. Background Sync, when enabled, is only a wake-up trigger for that queue; it is never the queue. One outbox, and it is realtime's, in the page's IndexedDB, keyed by principal. On a sync event the worker posts OUTBOX_DRAIN_MESSAGE ('x-outbox-drain', from @ultimat3/core) to every open tab and sends nothing itself. With no tab open the queue waits on disk for the next page load. As of 2026-09-22 (21.0.0) both halves are in the tree: the page's outbox listens for the message. Until 21.0.0 the worker POSTed /_x/outbox/flush, a route nothing ever mounted.

Manifest, icons, splash

From app.config.ts plus one source icon (SVG or >=1024px PNG). Emitted at /manifest.webmanifest by x dev, by the container and into the static export, As of 2026-08 — packages/cli/src/pwa-artifacts.ts composes pwa.name and pwa.colors with planIcons' own list, so the manifest names exactly the icons /icons/* serves. defineConfig refuses pwa.enabled: true without a name and both schemes' colours, so assertValid's two refusals now arrive at boot rather than at build:

GeneratedDetail
manifest.webmanifestthe DEFAULT locale's: lang is defaultLocale (was a hardcoded en until 22.3.3), start_url /, id pwa.id or /, theme + background from pwa.colors
/<locale>/manifest.webmanifestone per other routed locale, As of 22.3.3: its own lang, start_url: '/en/', the SAME id (one installed app), its text and its shortcut URLs in that locale. Each document links its own locale's manifest
Icons192/256/384/512 + maskable variants + apple-touch-icon
Favicons.ico + SVG
description, categories, shortcuts, screenshotsfrom pwa.*, As of 22.3.3 — below
// app.config.ts — every member optional; text is a string or one per locale
pwa: {
  enabled: true, name: 'Acme', offline: { fallback: '/offline' }, colors: { … },
  id: '/',                                     // default: '/'
  description: { 'es-co': '…', en: '…' },
  categories: ['business', 'productivity'],
  shortcuts: [{                                // url: the DEFAULT locale's path; en gets /en/panel
    name: { 'es-co': 'Panel', en: 'Dashboard' }, shortName?: …, description?: …,
    url: '/panel', icons?: [{ src: '/assets/…png', sizes: '96x96', type: 'image/png' }],
  }],
  screenshots: [{                              // src: one path, or one per locale
    src: { 'es-co': '/assets/…-es.png', en: '/assets/…-en.png' },
    sizes: '1280x800', type: 'image/png', formFactor: 'wide', label?: { … },
  }],
}

defineConfig refuses a relative id, shortcut url or screenshot src — a browser drops those in silence. A record missing a locale falls back to the default locale's entry.

A screenshot or shortcut-icon src under /assets/ is a file in apps/web/site/assets/, and the manifest names its content-hashed URL (/assets/pwa/wide-es-co.1a2b3c4d.png), exactly as asset() does in a page — the site serves an asset only there (As of 22.4). A missing file is X_ASSET_MISSING at boot; any other src is published as written.

No icon-generator service, no 30-file public/ directory to maintain. Theme and background colours come from semantic theme tokens, never a raw hex.

Offline fallback is required in the type

// app.config.ts — omitting `offline.fallback` is a compile error
pwa: {
  offline: { fallback: '/offline' },   // required
}

x new scaffolds /offline in site/ — 0kb JS, works from cache, shows the queued-mutation count. A PWA without a fallback shows the browser's dinosaur, which reads as "the app is broken", not "you are offline". Making it a required field means no app ships without one.

Version skew is what actually breaks PWAs

Not caching strategy. Skew: a client running build A requesting an asset from build B.

user opens app (build A) → keeps tab open 3 days → you deploy 6 times
  → user clicks a route → lazy chunk from build A → 404 → white screen

The client is not broken and the server is not broken; they disagree about which version exists. Mitigations, all mandatory:

#MechanismDetail
1Immutable build ID per deploycontent-hash of the build, stamped into sw.js, the HTML, every asset path, and x.manifest.json. Never a timestamp, never latest
2Client sends its build ID on every requestX-Ultimate-Build header on RPC, query, and WS handshake. The server can answer "you are stale" instead of guessing
3N-deploy asset retentionassets: { retain: { disk, builds } } in app.config.ts: every web pod publishes its hashed files to that shared disk before it reports ready, and answers an earlier build's /islands, /styles and assets from it on a miss. Unset (the default), a pod serves only what it built. A count of deploys, with no time component. Proven in a browser As of 2026-11: a tab opened on build 0 survives six deploys that each change an island, and its first click boots build 0's chunk with a 200 and no 404 (six-deploys.e2e.test.ts); without retain the same click is a 404. retentionPlan was removed in 30.0.0: it kept nothing on its own, because no boot called it
4AppUpdateAvailable signal, not a 404a Solid signal flips when the server reports a newer build. The app renders its own "Update available — reload" affordance. No forced navigation, no lost form state, no dinosaur
5Forced reload after a grace periodNot a capability of this package. Deleted in 9.0.0 — updateSignal/updatePolicy computed a deadline nothing acted on, and pwa (tier 4) sits above the two runtimes that hold both build ids. The service worker posts { type: 'AppUpdateAvailable', to }; act on it yourself, or read useConnection().updateAvailable
6Skew is observablethe /_x live panel reports the build-ID distribution of connected clients, so "how many users are three deploys behind" has an answer. x status --json would report it outside dev and is planned, not shipped
7Build ID scopes the SW cachepreview/branch builds get their own cache namespace and SW scope, so a preview can never poison prod caches

Server behaviour on a stale build ID:

Request typeResponse
Asset still within retentionserve it
Asset outside retention410 Gone + X-Ultimate-Build-Current, SW serves the fallback and flips AppUpdateAvailable
Action / queryexecuted if the contract is compatible; X_BUILD_SKEW with a fix: line if the input schema changed incompatibly
WS handshakeaccepted, then an update-available frame carrying the server's buildId → signal flips; the socket is not killed

410 Gone rather than 404: a 404 is indistinguishable from a typo, and a client cannot act on it. 410 plus X-Ultimate-Build-Current is an instruction.

Opt-in capabilities

Off by default. Each flag adds permission prompts, review burden, or platform surface, so none is implicit.

// app.config.ts: the two capabilities an app can switch on, both booleans
pwa: {
  enabled: true,
  offline: { fallback: '/offline' },
  backgroundSync: true,
  push: true, // Web Push — requires vapid; the key pair is env (`x vapid create`), see below
  vapid: { subject: 'mailto:ops@example.com' },
}

badging, shareTarget and fileHandlers are CapabilityFlags on @ultimat3/pwa's generateServiceWorker, not app.config.ts keys, As of 2026-09-22. x build passes only backgroundSync and push through (packages/cli/src/sw-artifacts.ts). This block showed all five as config objects ({ enabled, queues }, { enabled, vapid }, …), and no such shape was ever accepted.

FlagGeneratesCost of enabling
pushthe SW push and notificationclick handlers, <meta name="x-push-key"> on every document, and the push runtime every role's boot installs — on a served boot (x dev, the container). A static export serves no subscribe action, so it emits no handler and files a serviceWorkerWarnings entry (cli.build.pushUnwired). A tap opens a same-origin URL only; any other opens the app rootnotification permission prompt (from a click); a VAPID key pair (x vapid create)
backgroundSynca SW sync listener that posts OUTBOX_DRAIN_MESSAGE to every open tab, and the page registration with an online fallback where the Background Sync API is missingreplay must be idempotent. By design each queued write carries an idempotency key, so action's idempotency store answers a replay rather than applying it twice. The queue is IndexedDB-backed, per principal
badgingbadge update from a live query — only alongside push As of 2026-08-20: the badge call is emitted inside the push block, so badging: true on its own changes nothing while capabilities.badging still reports trueChromium-only surface
shareTargetthe share_target manifest member, from generateWebManifest's shareTarget input — nothing else: no route, no policy, no worker code. x build passes neither the flag nor the input, so no app's manifest carries it, As of 2026-10-02the app serves the target route itself and must treat its payload as untrusted
fileHandlersmanifest entry + routeOS-level file association
periodicSyncnot built, and the declarations are deleted As of 2026-08-20. There was never a periodicsync listener, never a periodicSync.register call, and no CAPABILITIES flag to gate one — PERIODIC_SYNC_TAG and periodicMinIntervalMs described a feature that did not exist—

All of them are route / action / job primitives underneath (The eight primitives) — a push send would be a job, a share target is a route the app serves. No PWA-specific concept escapes into the app's mental model.

Push

Web Push, end to end, with no dependency — RFC 8291 message encryption and RFC 8292 (VAPID, ES256) signing on WebCrypto. As of 26.1.0.

PieceWhatWhere
configpwa: { push: true, vapid: { subject: 'mailto:ops@example.com' } } — a subject push services accept (mailto: or https:), required with push, refused without itConfiguration
key pairULTIMATE_VAPID_PUBLIC_KEY + ULTIMATE_VAPID_PRIVATE_KEY, never config. x vapid create mints a P-256 pair and seals BOTH into secrets.enc.json in one write; x vapid show names the pair in force. Locally, neither set signs with a published development pair; a deployed boot refuses it, half a pair, or two halves that do not sign for each other (X_PWA_VAPID_KEY_MISSING, X_PWA_VAPID_KEY_INVALID) — at boot, before any service startsCLI reference
subscriptionsx_push_subscriptions, a framework table every boot applies; keyed by endpoint (a browser subscribed by a second person is theirs now), with the locale and zone it subscribed in@ultimat3/pwa/schema
subscribepushSubscribe({ permission }) / pushUnsubscribe({ permission }) — factories returning real actions an app exports from its own actions module: route, OpenAPI, typed client, contract tests, the one policy path. Stored against ctx.actor.id; an agent is refusedapps/web/app/<slice>/actions.ts
the buttonsubscribeToPush({ save: client.subscribePush }) from @ultimat3/pwa/client (an import-free subpath an island can afford), on a click; answers subscribed, denied, unsupported or unconfiguredan island
sendpushChannel({ pusher: webPush(), message }) in a notifier — every device of the recipient, each in its own locale; or pushToActor(id, notification) directlyNotify
the devicethe emitted push handler shows title/body/tag/actions from the body; notificationclick focuses or opens the URL, same origin onlysw.js

Push strings are catalog KEYS (titleKey, bodyKey), rendered server-side per subscription locale — the sending server has no request to read a language off. One message is one 4096-byte record (X_PWA_PUSH_PAYLOAD_TOO_LARGE past 3993 bytes of notification): send a path to the content, never the content. ttlSeconds (default a day), urgency and topic (the push service's collapse key) travel as RFC 8030 headers.

What is checked, and where

x verify has no PWA step — its 21 steps are typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, exercise, e2e, eval, drift, contract-diff, budgets, seo, i18n, policy, manifest, roadmap. The two PWA checks that ship run in x doctor (packages/cli/src/cmd-doctor.ts:129), and report @ultimat3/pwa's own codes rather than CLI twins of them.

CheckWhereFails onAs of 2026-08
Source icon presentx doctorthe 1024×1024 source PNG is missing, so install icons and OG images cannot be generated (X_PWA_ICON_MISSING)shipped
Offline fallback route presentx doctorno site/ route serves the configured pwa.offline.fallback, so an offline navigation lands on the browser error page (X_PWA_NO_OFFLINE_FALLBACK). Judged against the route table (describePages()), never against a filename — it named apps/web/app/offline.tsx, which is not a route file at all, so the check was red for every app the framework ever produced and no invocation could clear it. site/ only: app/ allows stream | ssr, only a static route is prerendered, and the worker precaches a rendered document — an app/ fallback has noneshipped
SW scope@ultimat3/pwathe service-worker scope cannot serve the routes it precaches (X_SW_SCOPE_INVALID)shipped
SW checksum—nothing computes one; X_SW_HAND_EDITED is reservednot built
Strategy coherence—an offline value contradicting the route's render is accepted; X_SW_UNCACHEABLE is reservednot built
Precache budget—no step reads precache bytesnot built
Retention config@ultimat3/coredefineConfig refuses an assets.retain of the wrong shape or with builds under 1 (X_CONFIG_INVALID); the web role checks disk against the app's defineStorage({ disks }) when it bootsshipped
Build ID present@ultimat3/pwagenerateServiceWorker was handed an empty build id, so caches cannot be keyed and skew cannot be detected (assertBuildId)shipped
Build ID shape—only emptiness is rejected. A timestamp or latest is accepted, and both defeat the immutable-namespace guaranteenot built
Offline fallback declared@ultimat3/pwano offline block, or one with no fallback route — generateServiceWorker refuses with X_PWA_NO_OFFLINE_FALLBACK rather than building a worker that falls through to the browser error pageshipped

x test e2e additionally asserts SW install, offline fallback rendering, and the version-skew reload path in a real browser. See Testing.

Rules

  • Never hand-edit sw.js. Change the route, rebuild. Convention, not a rule — nothing checks it As of 2026-08.
  • Never cache an authenticated response without an explicit offline field on the route.
  • Never use a timestamp or latest as a build ID. Convention, not a rule — assertBuildId rejects only an empty one.
  • Never force-reload a user without a grace period, except on a --critical deploy.
  • Never cache a mutation. Offline writes are the tier-3 mutator queue's job.
  • Never ship a PWA without an offline.fallback route — generateServiceWorker refuses (X_PWA_NO_OFFLINE_FALLBACK). A runtime refusal at build time, not a type error: OfflineConfig is passed as a Partial.

Edit this page on GitHubThis page as markdown