- Aug 1, 2026
- 7 min read
Ship a Python Library as an Installable Web App with Pyodide
The MBSD mechanism solver is a Python library: NumPy, SciPy, about 54 KB of
my own code in a wheel. I wanted people to try it without installing
anything, so the app at app.multibodysystemsdynamics.com runs the real
library in the browser through Pyodide.
The numbers I ended up with:
| Measurement | Value |
|---|---|
| First load (runtime + NumPy + SciPy + app) | ~30 MB |
| Boot to “solver ready” | ~7–8 s |
| One mechanism solve | < 0.6 s |
| Largest single file (SciPy wheel) | 13.4 MiB |
| Cloudflare Pages per-file limit | 25 MiB |
| The library’s own wheel | 54 KB |
No backend. After the first visit it works offline. These are the decisions and the traps between a prototype that “worked on my machine” and something I can redeploy without fear.
Install the wheel, not the source
My first prototype copied src/mbsd/*.py into Pyodide’s virtual filesystem
and imported it. It worked until the library read its own version:
# mbsd/__init__.py
from importlib.metadata import version
__version__ = version("mbsd")
Raw source files have no installed metadata, so this raises
PackageNotFoundError. Any library that uses importlib.metadata, entry
points or package resources will break the same way.
The fix is to install the built wheel, metadata included. Pyodide can unpack a wheel straight into its site-packages:
const response = await fetch(new URL(MBSD_WHEEL_PATH, baseUrl));
const wheel = await response.arrayBuffer();
if ((await sha256Hex(wheel)) !== MBSD_WHEEL_SHA256) {
throw new Error("The MBSD wheel failed its integrity check.");
}
pyodide.unpackArchive(new Uint8Array(wheel), "wheel");
Now import mbsd; mbsd.__version__ returns 0.7.0, exactly as in CPython.
I didn’t need micropip at all: NumPy and SciPy come from Pyodide’s own
package set via loadPackage, and my pure-Python wheel is unpacked directly.
Pin what visitors run
The second prototype problem was that it copied whatever branch of the library happened to be checked out next to it. A visitor could run code that was never released.
Now a small file decides the version:
{ "version": "0.7.0", "ref": "v0.7.0" }
A build script stages the wheel under a path named after its hash and generates a TypeScript module with the expected digest:
// Generated by scripts/sync-mbsd-wheel.mjs. Do not edit.
export const MBSD_VERSION = "0.7.0";
export const MBSD_WHEEL_PATH = "python/2ea27563b55a/mbsd-0.7.0-py3-none-any.whl";
export const MBSD_WHEEL_SHA256 = "2ea27563b55a27b4…";
The wheel itself is committed to the private app repo. Committing 54 KB beats depending on PyPI or on a sibling checkout during builds: no network, no folder-layout assumptions, and the hash is verified twice (at build time and in the browser). When building from source instead, a strict mode refuses to deploy unless the library checkout sits exactly on the pinned tag with a clean tree.
One trap here: uv build --wheel --out-dir vendor writes a .gitignore
containing * into the output folder. My wheel was silently ignored by git
until I noticed. Delete that file after building.
Keep Pyodide off the main thread
Seven seconds of booting on the main thread freezes everything: no spinner animation, no scrolling. Pyodide runs in a Web Worker, and the page talks to it with messages:
main thread (UI, canvas, plots, URL state) <--> worker (Pyodide + NumPy + SciPy + mbsd)
The worker reports four boot steps (“Loading the Python runtime”, “Loading NumPy and SciPy”, “Installing MBSD 0.7.0”, “Solver ready”), so the 30 MB first load comes with an honest progress bar instead of a blank screen.
A related lesson: don’t rebuild the whole DOM after every solve. My first UI re-rendered on each result and killed slider focus mid-drag.
The service worker is the real feature
People think of a PWA as “an icon on the home screen”. For this app the valuable part is the cache: a repeat visit skips the 30 MB download and boots from disk. I deliberately did not add an “Install” call to action; the install prompt is the browser’s default and that’s enough.
The prototype service worker had three bugs worth naming:
- Cache-first for
index.html, so users stayed on an old version until I manually bumped a cache name. - Cached failed responses.
Promise.allSettledon the precache, which silently swallowed a missing SciPy file. The app claimed “offline ready” and then failed offline.
The rewrite is generated at build time from a template:
const CACHE = `mbsd-app-${VERSION}`; // VERSION = content hash of the build
self.addEventListener("install", (event) => {
// addAll is atomic: if any asset fails, the install fails
// and the previous version keeps serving.
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)));
});
- Navigations are network-first with the cached shell as offline fallback, so a deploy is picked up on the next visit.
- Assets are cache-first; old caches are deleted on activate.
- A new version waits and the page shows a “Reload” banner, instead of swapping assets under a running session. The page also checks for updates when the tab regains focus.
Caching headers that match the file names
Everything heavy lives under paths that change when the content changes:
/vendor/pyodide/<version>/* immutable, max-age one year
/python/<sha12>/* immutable
/assets/* immutable (Vite content hashes)
/, /index.html, /sw.js, /manifest.webmanifest no-cache
On Cloudflare Pages this is a _headers file. If the zone is proxied, check
that Cloudflare respects existing headers: mine rewrote sw.js to a 4-hour
cache. Browsers bypass the HTTP cache when checking for a new service worker
script, so it was harmless, but surprising.
Fonts belong in src/ so Vite fingerprints them; absolute /fonts/ paths
break as soon as the app is served from a subpath.
Deploying on a Pages subdomain
The app is a static dist/ folder deployed by direct upload to its own Pages
project, with app. attached as a custom domain:
make deploy # strict pin + tests + wrangler pages deploy dist
Two Pages details cost me time. The production branch is a per-project
setting, and deploying any other branch name silently creates a preview. And
a DNS record pointing at *.pages.dev is not enough: the hostname must also
be added under the project’s Custom domains, or you get a 522.
Why a subdomain rather than /app/ on the main site is its own question:
app.domain or domain/app?
Test the exact browser path in Node
Pyodide runs in Node, which makes the most valuable test cheap. A smoke script loads Pyodide from the same vendored directory the browser uses, loads NumPy and SciPy, unpacks the pinned wheel, imports the demo module and solves every demo, including one that must fail (a four-bar that can’t assemble):
const pyodide = await loadPyodide({ indexURL, stdout: () => {}, stderr: () => {} });
await pyodide.loadPackage(["numpy", "scipy"]);
const wheel = await readFile(wheelPath);
pyodide.unpackArchive(new Uint8Array(wheel), "wheel");
Note the new Uint8Array(wheel): Node’s readFile returns a Buffer, and
unpackArchive wants a plain Uint8Array. In the browser arrayBuffer()
needs the same wrapping.
The demos themselves are plain Python using only the library’s public API,
so the same file is also tested in CPython. make test runs both, and
make deploy refuses to upload if either fails.
What I’d do again
- Install the wheel with its metadata. Mounting source files is a
prototype shortcut that breaks on the first
importlib.metadatacall. - Pin the library to a tag and a hash, and make the deploy fail otherwise.
- Run Pyodide in a Web Worker with step-by-step progress.
- Treat the service worker as the product: atomic precache, network-first navigation, content-hashed versions, an explicit reload prompt.
- Test the real loading path in Node instead of trusting a browser demo.
The next problem was making people find the app at all, since a 30 MB canvas has no text for a search engine to index. Next: where to put a lead-magnet PWA, and what it means for SEO.