Python in the Browser & WebAssembly (Pyodide, PyScript, WASI/CPython)

Python in the Browser & WebAssembly (Pyodide, PyScript, WASI/CPython)

Overview

“Python in the browser” means compiling a Python interpreter to WebAssembly (WASM) so it runs inside the browser’s WASM VM (or a server-side WASM runtime) instead of a native OS process. There is no native Python in a browser; everything routes through one of two WASM targets of CPython:

A third lever is the interpreter choice: full Pyodide (CPython) — large but complete, with NumPy/SciPy/pandas — versus MicroPython — tiny (~300 KB) and near-instant but a reduced language/stdlib. PyScript lets you pick per-page.

The defining reality of all WASM Python: single-threaded, sandboxed, no OS. No real threads, no raw sockets, no blocking stdin, and a multi-megabyte cold-start download for full CPython. Design around these from the start.

Browser tab ── Pyodide (wasm32-emscripten) ──► JS host (DOM, fetch, Web APIs) via FFI
Web Worker ── Pyodide/MicroPython ───────────► off-main-thread, keeps UI responsive
Server/edge ── CPython (wasm32-wasi) ────────► wasmtime/Wasmer, capability-gated FS/net
PyScript ──── polyscript core ───────────────► orchestrates either runtime + DOM components

Core Concepts

1. The CPython → WASM compile pipeline (shared foundation)

Both targets cross-compile CPython. Emscripten produces a .wasm + a JS loader (pyodide.js) plus a packaged stdlib; WASI produces a python.wasm you feed to a runtime. WASM is single-threaded by default; pthreads need SharedArrayBuffer + cross-origin isolation (COOP/COEP headers) and are still not generally usable for CPython’s threading model. The PEP 11 platform tiers govern support: WASI is tier 2 (3.13+, PEP 816 pins WASI + WASI-SDK versions per release), Emscripten is tier 3 (3.14+, PEP 776 defines it). Everything else (Pyodide FFI, packaging) layers on top of this.

2. Pyodide — the browser CPython distribution

Pyodide is a port of CPython to Emscripten/WASM providing three things: (a) the CPython interpreter compiled with Emscripten + a few patches; (b) a JS⟺Python foreign function interface (FFI); (c) a catalog of third-party packages (NumPy, pandas, scikit-learn, etc.) precompiled to WASM. Loaded with the async loadPyodide(); run code with pyodide.runPython(code) (sync) or pyodide.runPythonAsync(code) (supports top-level await via eval_code_async). Hit ~1B+ JsDelivr requests in 2025; usage doubling year-over-year. Also powers Cloudflare Python Workers and Node.js Python embedding.

3. The foreign function interface (FFI) and PyProxy/JsProxy

The FFI is the heart of in-browser Python. Two translation strategies: convert (copy a value into a native object of the other language) or proxy (wrap the original). Crossing the boundary yields proxies: a Python object handed to JS becomes a PyProxy; a JS object handed to Python becomes a JsProxy. PyProxy.toJs() (JS) / to_js() (Python) does an explicit deep conversion; create_proxy() wraps a Python callable as a persistent JS function (e.g. for addEventListener). PEP 818 is upstreaming the core of this FFI into CPython itself (the js module + proxy machinery) so it’s standard, not Pyodide-private.

4. PyScript — the framework layer

PyScript is not a runtime; it’s a framework built on a small core called polyscript that orchestrates a runtime (Pyodide or MicroPython) plus DOM integration. You write <script type="py">…</script> (Pyodide) or <script type="mpy">…</script> (MicroPython), configure via <py-config>/<mpy-config> or an external pyscript.toml/.json, and get components like <py-editor>/<mpy-editor> (REPL widgets) and the pyscript Python module (display(), when, PyWorker, DOM access). Pyodide is the default runtime “for the foreseeable future”; MicroPython is the lightweight option.

5. MicroPython vs Pyodide (the size/capability tradeoff)

6. Packaging for WASM Python (PEP 783 / PyEmscripten ABI)

Historically you could not put WASM wheels on PyPI; you used anaconda.org or jsdelivr. PEP 783 (accepted) defines a pyodide_${YEAR}_${PATCH}_wasm32 platform tag (the PyEmscripten ABI) so binary wheels can ship on PyPI. Key rule: one ABI per Python version — wheels built for one Pyodide build work across all Pyodide versions sharing that Python version. Build with pyodide build/pyodide-build or cibuildwheel; no Docker needed (just Linux + matching Python/Node/Emscripten). Pure-Python wheels install via micropip.install(); Pyodide-built C-extension packages also load via pyodide.loadPackage() (lower overhead, more limited).

7. The WASI/CPython target (server-side & sandboxed)

WASI CPython runs outside the browser in a WASM runtime (wasmtime is the officially recommended one). Use cases: sandboxed plugin execution, edge functions, embedding Python as a guest with host-provided functions, and capability-secure execution (the runtime grants explicit FS/socket capabilities; nothing is ambient). Cross-compile via Tools/wasm/ / Platforms/WASI in the CPython tree (configure for the build Python, then the host WASI Python with the WASI-SDK). You can even drive a WASI CPython from a host and extend it with host functions. PEP 816 governs which WASI/WASI-SDK versions a CPython release targets.

Tools / Frameworks

Tool Layer Use it for
Pyodide CPython on Emscripten/WASM Full Python in a browser tab / Node / Cloudflare Workers; scientific stack
PyScript Framework over polyscript Declarative <script type="py"> apps, DOM components, runtime switching
polyscript PyScript’s core Low-level runtime orchestration (used by PyScript)
MicroPython (WASM) Lightweight interpreter Instant-start, small-footprint browser Python
micropip Python pkg installer (Pyodide) Install pure-Python/Pyodide wheels from PyPI/URL at runtime
pyodide-build / cibuildwheel Build toolchain Produce PEP 783 pyodide_* wheels
wasmtime WASI runtime Run wasm32-wasi CPython server-side/sandboxed
WASI SDK / Emscripten SDK Compilers Cross-compile CPython to the respective WASM target

Methodology — choosing & wiring an approach

  1. Where does it run? Browser DOM → Emscripten (Pyodide/PyScript). Server/edge/plugin/sandbox → WASI + wasmtime.
  2. How much Python do you need? Scientific stack / real CPython → Pyodide. Tiny + instant → MicroPython.
  3. Hand-rolled or framework? Direct JS control / embedding → Pyodide JS API. Declarative HTML app → PyScript.
  4. Keep the UI alive: run the interpreter in a Web Worker so heavy compute / package loads don’t block the main thread.
  5. Plan the FFI boundary: decide convert-vs-proxy per value, and own PyProxy/JsProxy lifetimes (destroy explicitly).
  6. Package strategy: prefer PyPI wheels via micropip; for C extensions ensure a PEP 783 pyodide_* wheel or a Pyodide-built package exists.

Practical Patterns

Bootstrap Pyodide and run code (browser/Node):

const pyodide = await loadPyodide();              // async; loads the WASM + stdlib
await pyodide.loadPackage("numpy");                // load a Pyodide-built package
await pyodide.runPythonAsync(`
    import numpy as np
    np.arange(5).sum()
`);                                                 // top-level await supported

Install a pure-Python PyPI wheel at runtime:

await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
await micropip.install("snowballstemmer");          // from PyPI / JsDelivr / URL

FFI: persistent callback + explicit cleanup:

const proxy = pyodide.runPython("lambda evt: print('clicked', evt.type)");
const handler = proxy.create_proxy ? proxy : pyodide.ffi.create_proxy(proxy);
document.body.addEventListener("click", handler);
// later, when removed: handler.destroy(); proxy.destroy();  // avoid the leak

PyScript app with external config + worker:

<script type="py" src="./main.py" config="./pyscript.toml" worker></script>
# pyscript.toml
packages = ["pandas"]              # micropip names (Pyodide only)
[files]
"./data.csv" = "data.csv"          # mount a file into the virtual FS

Run CPython under WASI with wasmtime (server-side):

wasmtime run --dir=. python.wasm -- script.py     # --dir grants FS capability explicitly

Anti-Patterns

Troubleshooting

Symptom Likely cause / fix
Page freezes during load/compute Interpreter on main thread → move to Web Worker
ModuleNotFoundError after micropip.install Package has C extensions with no Pyodide/PEP-783 wheel; try loadPackage, find a Pyodide-built build, or pick a pure-Python alt
Memory grows / tab eventually crashes Un-destroyed PyProxy/JsProxy or reference loops; call .destroy(), break loops; also bounded by browser memory
threading/multiprocessing errors Unsupported in Pyodide; disable/patch the threaded path
Network call fails (CORS / “not allowed”) Browser sandbox + CORS; only HTTP(S); use pyodide.http/fetch and a proxy for TCP
Streaming download falls back to full body Streaming needs a Web Worker on a cross-origin-isolated site (COOP/COEP)
Huge first-load latency Full CPython cold start; switch to MicroPython, lazy-load, or cache the runtime
WASI build can’t read files wasmtime grants no ambient FS; pass --dir to grant the capability

References