Python in the Browser & WebAssembly (Pyodide, PyScript, WASI/CPython)
Parent: Programming Languages · researched 2026-06-01T05:33:13.085Z· 15 sources · 9 concepts · skill python-in-browser-wasm
"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 n
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: [source]
- wasm32-emscripten - CPython compiled with Emscripten, which emulates a POSIX-ish environment (a virtual filesystem, a JS-backed libc) on top of the browser/JS host. This is the target Pyodide ships, and is the one that runs in a browser tab or in Node.js. CPython tier-3 since 3.14 (restored Oct 2024). [source]
- wasm32-wasi - CPython compiled against the WASI (WebAssembly System Interface) ABI, a capability-based syscall layer. Runs in standalone WASM runtimes (wasmtime, Wasmer, WasmEdge) for server-side/edge/sandboxed/plugin use, not the browser DOM. CPython tier 2 since 3.13.0 (the first final release to ship it). [source]
- 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. [source]
- 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. [source]
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. [source]
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. [source]
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. [source]
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. [source]
5. MicroPython vs Pyodide (the size/capability tradeoff)
- Pyodide (CPython): ~11 MB+ runtime download (full distribution effectively ~15 MB; big packages like pandas/SciPy add more), slow cold start, but real CPython with the C-extension scientific stack and micropip/PyPI. [source]
- MicroPython: ~300 KB total, loads instantly and runs in <100 ms, ideal for mobile/constrained/educational/visualization use. No micropip/PyPI; uses mip + micropython-lib. A reduced language and stdlib. [source]
- Choose MicroPython when startup latency and footprint dominate; choose Pyodide when you need the real CPython ecosystem. [source]
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). [source]
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. [source]
Methodology — choosing & wiring an approach
- Where does it run? Browser DOM → Emscripten (Pyodide/PyScript). Server/edge/plugin/sandbox → WASI + wasmtime. [source]
- How much Python do you need? Scientific stack / real CPython → Pyodide. Tiny + instant → MicroPython. [source]
- Hand-rolled or framework? Direct JS control / embedding → Pyodide JS API. Declarative HTML app → PyScript. [source]
- Keep the UI alive: run the interpreter in a Web Worker so heavy compute / package loads don't block the main thread. [source]
- Plan the FFI boundary: decide convert-vs-proxy per value, and own PyProxy/JsProxy lifetimes (destroy explicitly). [source]
- Package strategy: prefer PyPI wheels via micropip; for C extensions ensure a PEP 783 pyodide_* wheel or a Pyodide-built package exists. [source]
Practical Patterns
Anti-Patterns
- Assuming threads/multiprocessing work. Pyodide has no threading or multiprocessing; packages using them need patching to disable it. Don't port a thread-pool design unchanged. [source]
- Expecting raw sockets / blocking network. No raw socket access; only HTTP(S), subject to CORS; no synchronous networking on the main thread. The socket module is present but always non-blocking and needs a server-side WebSocket-to-TCP proxy. [source]
- Leaking PyProxy/JsProxy. A return-value PyProxy must be destroy()-ed or it leaks; a JS→Python→JS reference loop never gets GC'd. Don't rely solely on FinalizationRegistry. [source]
- Blocking the main thread. Loading Pyodide + big packages on the UI thread freezes the page. Use a Web Worker. [source]
- Shipping full Pyodide for trivial logic. Don't download 15 MB to run 10 lines - use MicroPython, or lazy-load Pyodide only when needed. [source]
- Treating files as persistent. The Emscripten virtual FS is in-memory; files vanish on refresh/close unless you persist them out (IndexedDB/server). [source]
- Putting raw WASM wheels on PyPI pre-PEP-783, or mixing ABIs. Use the pyodide_* tag and respect one-ABI-per-Python-version. [source]
References
- Pyodide - official site & docs (architecture, JS API, type conversions, packaging): https://pyodide.org/ ; usage: https://pyodide.org/en/stable/usage/ ; WASM constraints: https://pyodide.org/en/stable/usage/wasm-constraints.html ; PyEmscripten ABI: https://pyodide.org/en/stable/development/abi.html [source]
- Pyodide GitHub: https://github.com/pyodide/pyodide ; blog (0.26/0.28 releases): https://blog.pyodide.org/ [source]
- PEP 818 - Adding the Core of the Pyodide FFI to Python: https://peps.python.org/pep-0818/ [source]
- PEP 783 - Emscripten Packaging (accepted; pyodide_* wheel tag): https://peps.python.org/pep-0783/ [source]
- PEP 776 - Emscripten Support (tier-3 target def): https://peps.python.org/pep-0776/ [source]
- PEP 816 - WASI Support (WASI/WASI-SDK version policy): https://peps.python.org/pep-0816/ [source]
- PyScript docs - configuration, workers, FFI, features: https://docs.pyscript.net/2025.3.1/user-guide/ ; polyscript: https://pyscript.github.io/polyscript/ [source]
- Anaconda - PyScript + MicroPython runtime (size/startup numbers): https://www.anaconda.com/blog/pyscript-updates-bytecode-alliance-pyodide-and-micropython [source]
- CPython WASI platform dir & build helpers: https://github.com/python/cpython/tree/main/Platforms/WASI ; Tools/wasm README: https://fossies.org/linux/Python/Tools/wasm/README.md [source]
- Cloudflare - Python Workers via Pyodide/WASM: https://blog.cloudflare.com/python-workers/ [source]
- Running CPython on WASI with wasmtime + host functions: https://www.manjusaka.blog/posts/2024/10/02/how-to-extend-the-wasi-python-by-using-host-function-en/ [source]
Children
- CPython→WASM compile pipeline (wasm32-emscripten tier 3 / wasm32-wasi tier 2, PEP 11 tiers, PEP 776/816, single-threaded WASM model) (frontier)
- Pyodide — CPython on Emscripten distribution (loadPyodide, runPython/runPythonAsync, loadPackage, micropip) (frontier)
- JS⟺Python FFI (PyProxy/JsProxy, toJs/to_js, create_proxy, destroy lifetime management, PEP 818 upstreaming) (frontier)
- PyScript framework (polyscript core, <script type=py>/<script type=mpy>, py-config/pyscript.toml, PyWorker, py-editor) (frontier)
- Pyodide vs MicroPython runtime tradeoff (~11-15 MB CPython+SciPy stack vs ~300 KB <100ms MicroPython) (frontier)
- WASM packaging — PEP 783 pyodide_* wheel tags + PyEmscripten ABI (pyodide build/cibuildwheel, one ABI per Python version) (frontier)
- WASI/CPython server-side target (wasmtime, capability-gated FS/net, sandboxed plugins, host functions) (frontier)
- WASM constraints & troubleshooting (no threads/multiprocessing, no raw sockets, web-worker offloading, cold-start size, in-memory virtual FS) (frontier)
- Cloudflare Python Workers (embedded Pyodide at the edge) (frontier)
Frontier under this node: CPython→WASM compile pipeline (wasm32-emscripten tier 3 / wasm32-wasi tier 2, PEP 11 tiers, PEP 776/816, single-threaded WASM model), Cloudflare Python Workers (embedded Pyodide at the edge), JS⟺Python FFI (PyProxy/JsProxy, toJs/to_js, create_proxy, destroy lifetime management, PEP 818 upstreaming), PyScript framework (polyscript core, <script type=py>/<script type=mpy>, py-config/pyscript.toml, PyWorker, py-editor), Pyodide vs MicroPython runtime tradeoff (~11-15 MB CPython+SciPy stack vs ~300 KB <100ms MicroPython), Pyodide — CPython on Emscripten distribution (loadPyodide, runPython/runPythonAsync, loadPackage, micropip), WASI/CPython server-side target (wasmtime, capability-gated FS/net, sandboxed plugins, host functions), WASM constraints & troubleshooting (no threads/multiprocessing, no raw sockets, web-worker offloading, cold-start size, in-memory virtual FS), WASM packaging — PEP 783 pyodide_* wheel tags + PyEmscripten ABI (pyodide build/cibuildwheel, one ABI per Python version)