PWA And Offline - developerz-ai/ultimate GitHub Wiki

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

Included Source
Every route with offline: 'precache' route table + its prerender() URLs
The island chunks those routes boot, every surface stylesheet, the framework scripts rendered documents / declared islands, As of 22.3.3
Fonts, icons, and priority images they reference asset graph
The offline fallback route required (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.

render Strategy Rationale
static network-first the document names this deploy's hashed assets; the precached copy is the offline answer
isr network-first the server's ISR cache is already stale-while-revalidate; a second, older copy in the browser only delays a deploy
ssr network-first caching a per-request render is a correctness bug; the offline fallback answers on failure
stream network-first the 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.

Message Sent by Worker does
{ type: 'clear-pages' } the page, on sign-out deletes 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 notificado.co). 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:

Generated Detail
manifest.webmanifest the 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.webmanifest one 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
Icons 192/256/384/512 + maskable variants + apple-touch-icon
Favicons .ico + SVG
description, categories, shortcuts, screenshots from 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: 'Notificado', 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:

# Mechanism Detail
1 Immutable build ID per deploy content-hash of the build, stamped into sw.js, the HTML, every asset path, and x.manifest.json. Never a timestamp, never latest
2 Client sends its build ID on every request X-Ultimate-Build header on RPC, query, and WS handshake. The server can answer "you are stale" instead of guessing
3 N-deploy asset retention the last 3 builds' assets stay served β€” retentionPlan(deploys, keep = 3) in packages/pwa/src/version-skew.ts. A count of deploys, with no time component: no 7-day minimum, and no pwa.retention config field β€” keep is a call-site argument. A build-A chunk resolves for two more deploys
4 AppUpdateAvailable signal, not a 404 a 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
5 Forced reload after a grace period Not 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
6 Skew is observable the /_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
7 Build ID scopes the SW cache preview/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 type Response
Asset still within retention serve it
Asset outside retention 410 Gone + X-Ultimate-Build-Current, SW serves the fallback and flips AppUpdateAvailable
Action / query executed if the contract is compatible; X_BUILD_SKEW with a fix: line if the input schema changed incompatibly
WS handshake accepted, 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:[email protected]' },
}

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.

Flag Generates Cost of enabling
push the 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 root notification permission prompt (from a click); a VAPID key pair (x vapid create)
backgroundSync a 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 missing replay 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
badging badge 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 true Chromium-only surface
shareTarget the 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-02 the app serves the target route itself and must treat its payload as untrusted
fileHandlers manifest entry + route OS-level file association
periodicSync not 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.

Piece What Where
config pwa: { push: true, vapid: { subject: 'mailto:[email protected]' } } β€” a subject push services accept (mailto: or https:), required with push, refused without it Configuration
key pair ULTIMATE_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 starts CLI reference
subscriptions x_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
subscribe pushSubscribe({ 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 refused apps/web/app/<slice>/actions.ts
the button subscribeToPush({ save: client.subscribePush }) from @ultimat3/pwa/client (an import-free subpath an island can afford), on a click; answers subscribed, denied, unsupported or unconfigured an island
send pushChannel({ pusher: webPush(), message }) in a notifier β€” every device of the recipient, each in its own locale; or pushToActor(id, notification) directly Notify
the device the emitted push handler shows title/body/tag/actions from the body; notificationclick focuses or opens the URL, same origin only sw.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 20 steps are typecheck, lint, boundaries, filesize, package-shape, errors, unit, contract, live, job, 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.

Check Where Fails on As of 2026-08
Source icon present x doctor the 1024Γ—1024 source PNG is missing, so install icons and OG images cannot be generated (X_PWA_ICON_MISSING) shipped
Offline fallback route present x doctor no 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 none shipped
SW scope @ultimat3/pwa the service-worker scope cannot serve the routes it precaches (X_SW_SCOPE_INVALID) shipped
SW checksum β€” nothing computes one; X_SW_HAND_EDITED is reserved not built
Strategy coherence β€” an offline value contradicting the route's render is accepted; X_SW_UNCACHEABLE is reserved not built
Precache budget β€” no step reads precache bytes not built
Retention config β€” retentionPlan(deploys, keep = 3) is a call-site argument, not a validated config field not built
Build ID present @ultimat3/pwa generateServiceWorker 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 guarantee not built
Offline fallback declared @ultimat3/pwa no 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 page shipped

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.
⚠️ **GitHub.com Fallback** ⚠️