Make your PyScript app a Progressive Web App (PWA)

August 14, 2026 · View on GitHub

Who: You host a pydevices-examples (or other PyScript) demo on GitHub Pages — or any HTTPS origin — and want users to install it like a native app and run it offline after the first visit.

Where PWAs run: Platform notes — host matrix, install UX, PWA vs Android APK — live in Progressive Web Apps. This guide is the how-to (manifest, service worker, deploy).

Prerequisites: A working PyScript HTML shell (see PyScript guide). Basic familiarity with browser DevTools.

Live reference: The pydevices-examples PyScript gallery is a PWA. Open DevTools → Application to inspect its manifest and service worker, or click Install app in the header.

!!! tip "Fastest path: use the template" Create a repository from pydevices-pyscript-template, edit main.py, and enable GitHub Pages with GitHub Actions. The template pins both PyScript and a PyDevices release and includes the manifest, service worker, icons, tests, and Pages workflow described below.


What a PWA is (and what Python does)

A Progressive Web App is still a website. Browsers treat it as installable when three pieces are in place:

RequirementPurpose
HTTPS (or localhost for dev)Secure origin; required for service workers
Web app manifest (manifest.json)Name, icons, theme colors, launch URL, display mode
Service worker (sw.js)Background script that can cache assets and enable offline use

Python runs inside the page via PyScript/Pyodide. It does not replace the manifest or service worker — those are standard web files served alongside your HTML.

flowchart LR
  subgraph browser [Browser]
    HTML[HTML shell]
    Manifest[manifest.json]
    SW[sw.js service worker]
    PyScript[PyScript / Pyodide runtime]
    App[Your Python example]
  end
  HTML --> PyScript --> App
  HTML --> Manifest
  HTML --> SW
  SW -->|cache| HTML
  SW -->|cache| PyScript

PyScript-specific constraints

PyScript demos are heavier than typical static sites. Plan for these up front.

SharedArrayBuffer needs Cross-Origin Isolation (COI)

MicroPython and Pyodide in the browser often need SharedArrayBuffer. That requires cross-origin isolated pages (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers).

GitHub Pages does not send those headers by default. The pydevices-examples gallery solves this in one service worker (.site/pyscript/sw.js) that:

  1. Intercepts fetches and adds COI headers to responses.
  2. Caches gallery assets for offline use.

!!! important "One service worker per scope" A given URL path can only have one active service worker. If you already register a worker (for example mini-coi-fd.js for COI), merge COI and PWA caching into a single sw.js instead of registering two scripts.

Register the worker before PyScript boots

COI must be active before the runtime tries to allocate SharedArrayBuffer. Load pwa.js synchronously in <head>, before vendor/core.js:

<script src="./pwa.js"></script>
<script type="module" src="./vendor/core.js"></script>

Do not use defer on pwa.js for loader pages (micropython.html, pyodide.html).

Offline caching is large

A first online visit downloads PyScript/Pyodide .wasm bundles and any packages your demo imports from CDNs. The service worker caches those on the fly. Storage can reach tens to hundreds of MB per demo.

Use stale-while-revalidate (serve cache immediately, refresh in background) rather than precaching every runtime file at install time — precaching everything often hits browser storage limits on first load.


Files you need

For a pydevices-examples-style deployment under .site/pyscript/, these are the PWA files in this repository:

FileRole
manifest.jsonInstall metadata
sw.jsCOI headers + offline cache
pwa.jsRegister sw.js, install button, offline toast
pwa.cssStyles for install button and toast
icon-192.png, icon-512.pngLauncher icons (PNG; manifest requires raster icons)

Gallery pages that include the full PWA UI: index.html, micropython.html, pyodide.html. Minimal shells (editor.html, repl.html, async.html, dom.html, harness.html) link the manifest and load pwa.js without the install button.


Step 1 — Create manifest.json

Place manifest.json next to your HTML entry point (same directory or adjust paths).

Use when users install the whole demo hub and pick a demo from the grid:

{
  "name": "PyDevices Gallery",
  "short_name": "PyDevices",
  "description": "Cross-platform display and event drivers — PyScript demos in your browser.",
  "start_url": "./index.html",
  "scope": "./",
  "display": "standalone",
  "launch_handler": {
    "client_mode": "navigate-existing"
  },
  "background_color": "#100e0b",
  "theme_color": "#f54e00",
  "icons": [
    {
      "src": "icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

Single-demo app (one install icon opens one module)

Use when the installed app should always launch the same demo, including query parameters:

{
  "name": "PyDevices Demo",
  "short_name": "Demo",
  "description": "Flagship PyDevices board_config demo in the browser.",
  "start_url": "./micropython.html?modules=pydevices_demo",
  "scope": "./",
  "display": "standalone",
  "launch_handler": {
    "client_mode": "navigate-existing"
  },
  "background_color": "#100e0b",
  "theme_color": "#f54e00",
  "icons": [
    { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

!!! tip "start_url and scope" - scope — URL prefix the PWA owns. Usually "./" for everything under .site/pyscript/. - start_url — page opened when the user taps the home-screen icon. Include ?modules= or ?manifests= when you want a fixed entry demo. - Paths are relative to the manifest file location.

Icons

Provide at least 192×192 and 512×512 PNG files. Maskable icons (safe zone in the center) improve Android adaptive icons.

You can adapt the PyDevices logo SVG or export PNGs from any design tool. The gallery icons live at .site/pyscript/icon-192.png and .site/pyscript/icon-512.png.


Step 2 — Create sw.js (service worker)

The service worker runs in the background. It can cache responses and, for PyScript, inject COI headers.

Minimal structure

  1. install — precache a small shell of HTML/CSS/JS you control (not the entire Pyodide tree).
  2. activate — delete caches from older versions when you bump CACHE_NAME.
  3. fetch — cache-first with background revalidation for your origin and known CDN hosts; add COI headers where needed.

Key constants from the pydevices-examples worker:

const CACHE_NAME = 'pydevices-examples-pwa-dev';  // stamped at Pages deploy (see below)

const STATIC_ASSETS = [
  './index.html',
  './micropython.html',
  './manifest.json',
  './icon-192.png',
  './icon-512.png',
  './site.css',
  './pwa.js',
  // add your shell files; avoid listing every vendor/*.js
];

const RUNTIME_ORIGINS = [
  'pyscript.net',
  'cdn.jsdelivr.net',
  'pyodide.org',
  'pydevices.github.io',
];

COI header helper

function withCoiHeaders(response) {
  const { body, status, statusText } = response;
  if (!status || status > 399) return response;
  const headers = new Headers(response.headers);
  headers.set('Cross-Origin-Opener-Policy', 'same-origin');
  headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
  headers.set('Cross-Origin-Resource-Policy', 'cross-origin');
  return new Response(status === 204 ? null : body, { status, statusText, headers });
}

Apply withCoiHeaders to same-origin responses (and shell assets) so SharedArrayBuffer works after the worker controls the page.

Caching strategy

Asset typeStrategy
Your HTML, CSS, manifest.json, iconsPrecache in install + stale-while-revalidate
vendor/ PyScript bundlesCache on first network fetch; do not cache.addAll the whole tree
CDN packages (jsDelivr, pyscript.net, …)Stale-while-revalidate per request URL
POST / non-GETDo not cache

CACHE_NAME stamping at deploy

Git keeps CACHE_NAME = 'pydevices-examples-pwa-dev' in .site/pyscript/sw.js. The Deploy PyScript site to GitHub Pages workflow runs scripts/pyscript_stamp_pwa_cache.py on the assembled _site/pyscript/ tree before publishing to gh-pages. The script hashes STATIC_ASSETS plus the service-worker source and rewrites CACHE_NAME to pydevices-examples-pwa-<hash> (12 hex chars).

That means:

  • Shell changes (HTML/CSS/PWA files listed in STATIC_ASSETS, or sw.js logic) produce a new cache id → activate deletes older caches → installed PWAs see fresh shell content and pwa.js can prompt Reload.
  • Example / src/ churn alone does not change the hash — gallery card updates do not nag installed users on every push. Stale-while-revalidate still refreshes pages in the background.

Local python tools/serve.py uses the git copy (pydevices-examples-pwa-dev); only the Pages artifact is stamped.

See the full implementation: .site/pyscript/sw.js.


In every HTML page that should participate in the PWA, add to <head>:

<link rel="manifest" href="./manifest.json">
<meta name="theme-color" content="#f54e00">
<link rel="apple-touch-icon" href="./icon-192.png">
<link rel="stylesheet" href="./pwa.css">
<script src="./pwa.js"></script>

pwa.js registers ./sw.js immediately (required for COI) and wires optional UI when elements exist.

Gallery demo cards intentionally omit target="_blank". Chromium treats same-scope _blank navigations from an installed PWA as a new app window; same-tab links keep demos in one window (browser or installed app). The manifest also sets launch_handler.client_mode to navigate-existing so captured launches reuse the recent PWA window. pwa.js still rewrites any leftover same-origin _blank clicks to in-place navigation. External links (Docs, GitHub, …) may keep _blank.

Install button (optional)

Add a button anywhere in your layout (the gallery puts it in the header):

<button type="button" class="pwa-install-btn" id="pwa-install-btn">Install app</button>

pwa.js shows the button when Chromium fires beforeinstallprompt, or on iOS (Safari and other WebKit browsers) when the page is not already running as a home-screen app. Chromium then calls prompt(); on iOS the button opens a short Share → Add to Home Screen tip (Safari has no programmatic install API).

Offline toast (optional)

No extra HTML is required. pwa.js creates a #pwa-toast element on first use and shows messages when:

  • the service worker finishes its first activation;
  • the browser goes online or offline.

Styles are in pwa.css.


Step 4 — Deploy

GitHub Pages (this repo)

Pushes to main that touch web/** or lib/** run Deploy PyScript site to GitHub Pages. The workflow:

  1. Verifies generated manifests are fresh (install_refresh_manifests.sh --audit, gallery_generator.py --check).
  2. Copies .site/pyscript/* into _site/pyscript/.
  3. Copies lib/utils and examples into _site/pyscript/lib/, adds canonical eventsys / multimer from a pydevices checkout at the gallery's lib/{eventsys,multimer} URLs, and overlays hardware-owned utilities (mip.py, byteswap, …).
  4. Stamps CACHE_NAME in _site/pyscript/sw.js from shell content (pyscript_stamp_pwa_cache.py).
  5. Publishes to the gh-pages branch.

Before pushing PWA changes, refresh gallery metadata locally:

./scripts/install_refresh_manifests.sh
python scripts/gallery_generator.py

Commit any updated packages/*.json files the scripts produce.

Other hosts

Any static host works if:

  • HTTPS is enabled (Let's Encrypt, Cloudflare, GitHub Pages, etc.).
  • manifest.json, sw.js, and icons are served from the same scope as your HTML.
  • sw.js is served with Content-Type: application/javascript (default on most hosts).

Step 5 — Test installability and offline use

Use Chrome or Edge on desktop, Chrome on Android, or Safari on iOS. On iOS the Install app button explains Share → Add to Home Screen (Safari never fires beforeinstallprompt).

Checklist

  1. Manifest

    • DevTools → ApplicationManifest
    • No errors; icons and start_url resolve.
  2. Service worker

    • DevTools → ApplicationService Workers
    • sw.js is activated and controls the page (may require one reload on first visit for COI).
  3. Install

    • Look for the install icon in the address bar, or your Install app button.
    • If you tested an install earlier, uninstall the PWA first — browsers suppress beforeinstallprompt for already-installed origins.
  4. Offline

    • Open your demo once online and click Run so Pyodide/MicroPython and packages download.
    • DevTools → Network → enable Offline.
    • Reload. The shell and cached runtime should still load; uncached assets fail until you go back online.
  5. COI / SharedArrayBuffer

    • DevTools → Console: no SharedArrayBuffer errors after the COI reload cycle.
    • Application → check that cross-origin isolation is active (or verify crossOriginIsolated === true in the console).
PageURL
Gallery (PWA home)pyscript/
Flagship demomicropython.html?modules=pydevices_demo
Calculatormicropython.html?modules=calc_graphics,calc_engine

Adapting for your own project

Fork or copy the PWA bundle

  1. Copy manifest.json, sw.js, pwa.js, pwa.css, and icon PNGs into your .site/pyscript/ tree (or equivalent).
  2. Edit manifest.json — name, colors, start_url.
  3. Edit STATIC_ASSETS in sw.js — list only pages and styles you ship.
  4. Add the <head> links and pwa.js script to your HTML shells.
  5. Optionally add #pwa-install-btn to your layout.

For a single-file demo like editor.html:

<head>
  <link rel="manifest" href="./manifest.json">
  <meta name="theme-color" content="#f54e00">
  <script src="./pwa.js"></script>
  <script type="module" src="./vendor/core.js"></script>
</head>

Point start_url at that page (or at a parametric loader URL with your module query).

Parametric loader (micropython.html / pyodide.html)

The gallery loader accepts:

  • ?modules=stem1,stem2 — install .py files from lib/examples/
  • ?manifests=name — install a MIP JSON manifest from packages/ (via .site/pyscript/packages)

For a dedicated PWA around one module, set:

"start_url": "./micropython.html?modules=your_module"

Users who install get that module every time. To ship multiple installable apps from one repo, use separate manifest files in subfolders (each with its own scope) or separate GitHub Pages projects.


Orphaned service workers and cache migration

The problem

Early pydevices-examples PWAs used a fixed CACHE_NAME (pydevices-examples-pwa-dev) that never changed between deploys. The activate handler only deletes caches whose names differ from the current CACHE_NAME:

if (key !== CACHE_NAME) {
  return caches.delete(key);
}

So when the same name is reused forever, stale shell assets can sit in Cache Storage indefinitely. Worse, after we moved to deploy-time hashing, clients still on the old worker kept the same cache name and never picked up the new stamped worker — a classic orphaned service worker loop.

What we did (July 2026)

We shipped a one-deploy migration worker in place of the normal offline sw.js. That temporary script:

  1. On installskipWaiting() immediately.
  2. On activatecaches.delete() for every cache name (not just mismatched ones), clients.claim(), then client.navigate() on all open tabs so the next load is network-fresh.
  3. On fetch — network only, but still injects COI headers so PyScript keeps working during the migration visit.

The deploy workflow detected a MIGRATION: cache-purge marker in sw.js and skipped CACHE_NAME stamping for that deploy (pyscript_stamp_pwa_cache.py). A follow-up deploy restored the normal offline worker; stamping resumed and legacy clients received a hashed cache id.

If you need this again

Keep a copy of the full offline worker (or restore from git history), replace sw.js with a purge-only worker for one Pages deploy, then restore. The migration worker must:

  • Change sw.js bytes so browsers install an update.
  • Delete all caches unconditionally.
  • Preserve COI on fetches during the purge visit.

Add MIGRATION: cache-purge to the migration source so the stamp script skips missing STATIC_ASSETS. Remove the marker when restoring the normal worker.


Troubleshooting

SymptomLikely causeFix
No install prompt / button never appearsAlready installed, or manifest/SW invalidUninstall PWA; fix manifest errors in DevTools
SharedArrayBuffer errorCOI not activeEnsure one sw.js adds COI headers; reload after first SW install
Offline reload fails immediatelyDemo never run onlineVisit once online; click Run to pull runtime + packages
Storage quota errorsPrecache list too aggressiveShrink STATIC_ASSETS; rely on fetch-time caching for WASM
Stale content after deployOld cache / fixed CACHE_NAMEEnsure deploy stamps CACHE_NAME; for stuck legacy installs see Orphaned service workers
Stale gallery card ?deps= on one machineindex.html was cache-first while CACHE_NAME ignores GEN:demosindex.html is network-first; hard-reload or clear site data once if still stuck
Pyodide: Can't find a pure Python 3 wheel for a cmodStale micropip IndexedDB / index metadata from before a wasm wheel existedLoader resolves pyemscripten_*_wasm32 via TestPyPI JSON to a direct .whl URL; clear site data if an old session still fails
iOS: Install tip but no native promptSafari has no beforeinstallpromptFollow Share → Add to Home Screen; tip is expected
micropython.html 404 on old linksRenamed from load.htmlUpdate bookmarks to micropython.html?modules=…

Reference (source files)

TopicLocation
Manifest.site/pyscript/manifest.json
Service worker.site/pyscript/sw.js
Client bootstrap + UI.site/pyscript/pwa.js, .site/pyscript/pwa.css
Deploy workflow.github/workflows/deploy-pyscript.yml
CACHE_NAME stamp scriptscripts/pyscript_stamp_pwa_cache.py
Gallery generatorscripts/gallery_generator.py