PWA & offline¶
What you'll learn
How to turn your app into an installable, offline PWA — service worker, a durable mutation queue, and end-to-end WebPush — with minimal code.
The PWA / offline-first / WebPush layer (Track P) makes your app
installable and able to run without a network. It is most turnkey in
Mode C (transpile): because the bundle is 100% static,
build --mode transpile emits the whole PWA on its own. 📱
PWA out of the box in Mode C
Start a PWA in one command:
This scaffolds a Mode C project already configured (mode = "transpile" + a
[pwa] block) with a counter and an Install button. A tempestweb build
--mode transpile emits the manifest, the icons, the sw.js (cache-first
service worker) and register.js — without you writing a line of plumbing. 🚀
The four pieces¶
-
Installable (P0)
Manifest + icons + install prompt. The app lands on the home screen like a native one.
-
Service worker (P1)
App-shell precache → offline after the 1st load + update lifecycle ("new version, reload").
-
Offline-first (P2)
Durable IndexedDB mutation queue + replay on reconnect (Background Sync).
-
WebPush (P3)
Subscribe in the browser (
native.notifications); send viawebpush_router(VAPID) on the server.
P0 — Installable app¶
The install prompt is exposed to Python through the
native.install capability. The controller already suppresses
the browser's cold mini-infobar and stores the beforeinstallprompt event, so you
show an "Install" button at the right time:
from tempestweb import native
async def maybe_show_install_button() -> bool:
"""Return whether an Install button should be shown."""
state = await native.install.state() # InstallState(can_install, installed)
return state.can_install and not state.installed
async def on_install_tap() -> None:
"""Fire the native install prompt from a button handler."""
outcome = await native.install.prompt() # "accepted" | "dismissed" | "unavailable"
Call the prompt after a user gesture
Browsers only allow install.prompt() from a real gesture (a click). Render
the button when can_install is true and fire the prompt in on_click.
Install method + decline cooldown (adopted from famachapp)¶
state.method classifies how the user installs here: "native" (a prompt is
available — show the button), "ios" (Share → "Add to Home Screen" — show a
tutorial) or "manual" (e.g. Firefox desktop). So the UI never shows a button
that does nothing on iOS.
On the JS side, client/pwa/install-prompt.js ships a decline cooldown so you
don't nag: recordInstallDecline() when the user dismisses the banner and
canPromptInstall() (7 days by default) before showing it again.
import { recordInstallDecline, canPromptInstall } from "/client/pwa/install-prompt.js";
if (canPromptInstall()) showInstallBanner();
// ... on "not now":
recordInstallDecline();
Post-install redirect¶
client/pwa/post-install-redirect.js shows a full-screen overlay when
appinstalled fires (the tab that installed is still a plain tab — the user
should switch to the standalone app). Opt-in:
import { mountPostInstallRedirect } from "/client/pwa/post-install-redirect.js";
mountPostInstallRedirect(); // no-op if already running standalone
P1 — Service worker: offline after the 1st load¶
In Mode C, the generated sw.js precaches the entire static bundle —
index.html, the shared client, your app.gen.js, the icons and the manifest.
After the first load, the app opens and runs without a network.
Truly offline ✅
With the HTTP server turned off, reloading the page still renders the app and navigation keeps working — verified live in Playwright. Because Mode C is a static, Python-free bundle, nothing depends on the server after the first fetch.
Test offline with build/run, not dev
tempestweb dev does not register the service worker on purpose (it injects
a kill-switch so you never see a stale cached bundle — see
Using the CLI). So the offline behavior only exists in the production
artifact: test it with tempestweb build --mode transpile (and serve the
dist/) or with tempestweb run --mode transpile.
Update prompt (automatic)
When you ship a new version, the old service worker stays live until the tab closes. The shell detects the waiting worker and shows a discreet banner "new version available → Update"; on confirm, the new worker takes over and the page reloads once. Nothing to write in the app.
P2 — Offline-first: durable mutation queue¶
Writes made offline survive. The native.offline capability
records each mutation in a durable IndexedDB queue (with an idempotency key) and
replays them in FIFO order when the connection returns — via the online event,
via Background Sync (tab closed) or explicitly:
from tempestweb import native
async def save_note(text: str) -> None:
"""Persist a note, queueing the write if we are offline."""
await native.offline.enqueue("POST", "/api/notes", {"text": text})
async def flush_when_online() -> None:
"""Replay any pending mutations in FIFO order."""
await native.offline.replay()
Inspect the queue with native.offline.size() and native.offline.pending(). A
mutation that fails permanently becomes a dead-letter
(native.offline.failed()) and a 409 conflict moves to the conflict lane
(native.offline.conflicts()) — neither wedges the queue. See
Offline + sync for the full cycle.
Replay needs idempotency
When the network returns, the queue re-sends the mutations. The server
dedups on the idempotency key, so a replay never applies the effect twice.
It is the same key from the native.http capability.
Caching large binaries (models, wasm)¶
For a large asset that does not belong in the precache (e.g. an ONNX model
downloaded from the API), client/offline/asset-cache.js does "download once,
version it, refresh on manifest change, serve cache-first offline" — adopted from
the famachapp model-sync pattern.
import { ensureCached, syncAssets } from "/client/offline/asset-cache.js";
// Downloads once; later loads come from cache (concurrent fetches are deduped).
const res = await ensureCached("/models/detect.onnx");
// On boot: re-downloads only if the version manifest changed; returns { refreshed }.
const { refreshed } = await syncAssets({
version: manifest.version,
assets: [{ url: "/models/detect.onnx" }, { url: "/models/classify.onnx" }],
});
if (refreshed) resetOnnxSessions(); // invalidate in-memory handles
Warmup + reset on refresh
Call syncAssets() on boot (when online) and, if it returns
refreshed: true, discard in-memory ONNX/Pyodide sessions so the next
inference uses the new bytes — no reload required.
P3 — End-to-end WebPush¶
The browser creates the subscription; the server sends. Both sides use the VAPID key that proves to the browser's push service that the send is legitimate.
On the client — native.notifications¶
from tempestweb import native
async def enable_push(vapid_public_key: str) -> None:
"""Ask for permission and subscribe the browser to WebPush."""
state = await native.notifications.push_state() # {supported, permission}
if not state.supported:
return
await native.notifications.request_permission()
sub = await native.notifications.subscribe(vapid_public_key)
# Send `sub` (subscription JSON) to your backend — via native.http
# or queued with native.offline. The framework does not decide your schema.
await native.http.request("POST", "/webpush/subscribe", json=sub)
On the server — tempestweb vapid + webpush_router¶
Generate the VAPID keypair once with the CLI and mount the ready-made router:
from fastapi import FastAPI
from tempestweb.server import WebPushService, webpush_router
app = FastAPI()
service = WebPushService() # reads the VAPID_* keys from the env
app.include_router(webpush_router(service)) # /webpush/subscribe, /send, …
webpush_router already exposes the subscribe and send endpoints; WebPushService
stores the subscriptions and fires the signed sends via
tempest-fastapi-sdk[webpush] (pywebpush).
iOS/Safari requires an installed PWA
On iOS (16.4+), WebPush only works with the PWA installed on the home screen. On desktop browsers and Android it works without installing. Test on a real device — see Manual verification.
The full flow has a page of its own
The WebPush end-to-end (server) example walks through key generation, the router, the subscription and the send, step by step, with a sequence diagram.
Configuring the manifest with [pwa]¶
Install metadata comes from an optional [pwa] section in your tempestweb.toml.
Every field is optional — without the section, the build uses sensible defaults
derived from the project name:
The full field list is documented on the Mode C — transpile page.
Turning the PWA off (enabled, manifest, service_worker)¶
Not every app wants offline precache. The clear case is the admin panel behind a login, served by a control plane: it gains nothing from offline — whoever is using it always has the network — and pays for it twice.
What the worker costs when you do not need it
- Stale assets after a deploy. The worker serves the shell from its precache until it updates, so the first load after a deploy can be the previous version.
- Connection contention on the first load. The precache fetches ~90 files while Pyodide (Mode A) is still booting.
The layer's two halves are separate axes, because they are useful apart:
That is the shortcut: it turns both off. No manifest.webmanifest, no
register.js, no <link rel="manifest"> and no worker registration in
index.html.
To turn off only one:
enabled is the default the two halves fall back to; naming a half
explicitly wins over it. So "off by default, except the manifest" reads:
The field must be a real boolean
service_worker = "false" (quoted) is rejected at build time. A non-empty
string is truthy in Python, and a switch whose whole job is to turn something
off must not do the opposite of what it reads.
Turning it off is not the same as never turning it on¶
Anyone who already visited the app already has the worker registered, and a
registered worker keeps serving the shell from its precache until it is replaced.
Simply not emitting sw.js any more would strand those people on the old build,
with nothing in a deploy able to reach them.
That is why the build still emits sw.js when you turn the worker off — just
a different worker: it clears every cache on the origin, unregisters itself, and
reloads the pages it controlled. It runs once per browser that still had the old
worker, then it is gone.
| File | Worker on | Off |
|---|---|---|
sw.js |
cache-first worker | teardown worker |
register.js |
emitted | not emitted |
registration in index.html |
present | absent |
manifest.webmanifest |
emitted | follows manifest |
| icons | emitted | emitted (favicon and apple-touch) |
The connectivity banner stays
It reports the network, not the precache, so an app with no worker still tells the user when the signal drops.
Manual verification¶
What requires a real device/browser
Some PWA guarantees cannot be fully automated; confirm by hand:
- Install the app from the prompt and open it from the home screen.
- Turn off the network and confirm the 2nd load opens the app (offline).
- Receive a WebPush notification — on iOS, with the PWA installed.
Recap¶
- The PWA is most turnkey in Mode C:
build --mode transpileemits the manifest, icons and service worker on its own. - Installable (P0) via
native.install; offline after the 1st load (P1) via the service worker that precaches the bundle. - The offline runtime (P2) uses an IndexedDB mutation queue with an
idempotency key (
native.offline). - WebPush (P3):
native.notifications.subscribeon the client;tempestweb vapid+webpush_routeron the server. - Some PWA tests require a real device — see the manual verification.
[pwa] enabled = falseturns the whole layer off;manifestandservice_workerturn off one half. Turning the worker off emits the teardown worker, so whoever already registered one is not stuck on the old precache.
For production health, see Observability. 🚀