• 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:

MeasurementValue
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 limit25 MiB
The library’s own wheel54 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:

  1. Cache-first for index.html, so users stayed on an old version until I manually bumped a cache name.
  2. Cached failed responses.
  3. Promise.allSettled on 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.metadata call.
  • 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.