PWA And Offline - developerz-ai/ultimate GitHub Wiki
x dev, the container andx build --target staticall emitmanifest.webmanifest,sw.jsandx-sw-register.js, plus the<head>that names all three. The manifest reader ispackages/cli/src/pwa-artifacts.tsand the worker's ispackages/cli/src/sw-artifacts.ts.This box said the opposite until 2026-08-27 β
generateServiceWorker,buildPrecacheManifest,offlineFallbackSource,backgroundSyncSourceandpushSourcehad zero callers outside@ultimat3/pwaitself, sopwa.offline,pwa.backgroundSync,pwa.pushand every route's ownoffline:field were declarations with no build behind them (#390, #362).What unblocked it was the browser check, not the emitter. A bad
sw.jsis 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.tsis 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.offlineis now a block, not a string β{ fallback, image, font, neverCache }, andfallbackis required onceenabledis true. See Upgrading for the one-line edit; the oldoffline: 'runtime'was an app-wide default for a fielddefineRoutemakes required on every route, so it defaulted nothing and was read by nobody.
pwa.pushis wired too,As of 26.1.0(#710): apwa.vapidkey, 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).
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.
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) }),
});| 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.
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.
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.
// 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.
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.
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.
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.
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.
- Never hand-edit
sw.js. Change the route, rebuild. Convention, not a rule β nothing checks itAs of 2026-08. - Never cache an authenticated response without an explicit
offlinefield on the route. - Never use a timestamp or
latestas a build ID. Convention, not a rule βassertBuildIdrejects only an empty one. - Never force-reload a user without a grace period, except on a
--criticaldeploy. - Never cache a mutation. Offline writes are the tier-3 mutator queue's job.
- Never ship a PWA without an
offline.fallbackroute βgenerateServiceWorkerrefuses (X_PWA_NO_OFFLINE_FALLBACK). A runtime refusal at build time, not a type error:OfflineConfigis passed as aPartial.