Skip to content

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:

tempestweb new myapp --template pwa

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 via webpush_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 bundleindex.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:

tempestweb vapid --env   # prints VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY
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:

[pwa]
name = "Weather Pro"
short_name = "WPro"
theme_color = "#0a84ff"
display = "standalone"

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:

[pwa]
enabled = false

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:

[pwa]
service_worker = false   # no precache, but the app is still installable
[pwa]
manifest = false         # precache, no install prompt

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:

[pwa]
enabled = false
manifest = true

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.

[pwa]
service_worker = false
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 transpile emits 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.subscribe on the client; tempestweb vapid + webpush_router on the server.
  • Some PWA tests require a real device — see the manual verification.
  • [pwa] enabled = false turns the whole layer off; manifest and service_worker turn 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. 🚀