How Python Runs in Your Browser (Pyodide and WebAssembly)

How Python Runs in Your Browser (Pyodide and WebAssembly)

Open a Python online runner, type import numpy as np, hit Run, and an array gets multiplied. No server, no SSH, no terminal. Your network tab shows zero requests after the page loads. So where did that Python interpreter come from, and how is NumPy — a library written largely in C — running in a browser tab that, last time you checked, only spoke JavaScript?

The short answer is WebAssembly, and a project called Pyodide that compiles CPython itself down to it. The longer answer is more interesting, because it changes what "the browser" actually is. Browsers are no longer JavaScript runtimes that occasionally render HTML. They are general-purpose virtual machines that happen to ship with one preferred language. Pyodide is the proof.

This post walks through how Pyodide actually works, why it took until 2018 for this to be possible, what the trade-offs look like in practice, and where it makes sense to reach for it.

Why the Browser Couldn't Run Python Before

For most of the web's history, the browser had exactly one execution model: a JavaScript engine plus the DOM. If you wanted to run Python in a web page, you had three bad options.

You could send code to a server, run it in a sandboxed container, and stream the output back. This is how repl.it, Glitch, and most "online IDE" products worked for years. It works, but every keystroke-to-output cycle pays a network round-trip. Sandboxing adds CPU overhead. Costs scale linearly with users.

You could transpile Python to JavaScript with a project like Brython or Skulpt. These reimplement Python's semantics in JS. They run in the browser without a server, but you're not running CPython — you're running a JavaScript program that pretends to be Python. Anything that depends on the C extension layer (NumPy, SciPy, Pillow, lxml) is off the table.

You could use a browser plugin, but those died with Flash and Silverlight, and good riddance.

None of these were satisfying. The first real shift came when browsers agreed on a portable bytecode target that wasn't JavaScript.

What WebAssembly Actually Is

WebAssembly (Wasm) is a binary instruction format for a stack-based virtual machine, designed to be a compilation target for languages like C, C++, and Rust. The browser ships a Wasm runtime alongside its JavaScript engine. Wasm modules execute at near-native speed, with a strict sandbox model and predictable performance characteristics.

The WebAssembly history on Wikipedia covers the design goals well. The short version: it had to be safe, fast, portable across browsers, and compact enough to ship over HTTP. The first MVP shipped in all major browsers in 2017. The MDN WebAssembly reference is the canonical place to dig into the API surface.

The key insight is that Wasm is not a Python runtime, not a Java runtime, and not anything specific. It's a low-level target that any language with a sufficiently capable compiler can produce. C compiles to Wasm via Emscripten. Rust compiles to Wasm via wasm32-unknown-unknown. Go has its own Wasm backend. Python, written in C, can be compiled to Wasm by compiling its interpreter.

That last sentence is what unlocked Pyodide.

Pyodide: CPython Compiled to Wasm

Pyodide is the official CPython interpreter, version 3.11 (or whichever is current), compiled to WebAssembly using Emscripten. When you load Pyodide, you are not loading "a Python-like thing." You are loading the same CPython source tree that runs on your laptop, executing inside the browser's Wasm sandbox.

The Pyodide project documentation covers the architecture in detail. The core idea: Emscripten produces a .wasm binary plus a JavaScript loader. Loading Pyodide looks like this:

<script src="https://cdn.jsdelivr.net/pyodide/v0.27.0/full/pyodide.js"></script>
<script>
  async function main() {
    const pyodide = await loadPyodide();
    const result = pyodide.runPython(`
      import sys
      f"Hello from {sys.implementation.name} {sys.version_info.major}.{sys.version_info.minor}"
    `);
    console.log(result);  // "Hello from cpython 3.11"
  }
  main();
</script>

That's a complete working example. loadPyodide() fetches the Wasm binary and the standard library, instantiates the interpreter, and returns an object you can drive from JavaScript.

The original Pyodide announcement on Mozilla Hacks from 2019 walks through the motivation: enabling the scientific Python stack — NumPy, Pandas, Matplotlib, SciPy — to run on Jupyter notebooks served as static files, no kernel server required. Iodide, the project that birthed Pyodide, is gone, but the runtime survived and grew into a general-purpose tool.

How NumPy Runs Without a C Compiler

Here's where it gets clever. NumPy is mostly C code with Python bindings. To get NumPy in the browser, you need to compile NumPy's C code to Wasm — once, ahead of time — and ship the resulting .so-equivalent files alongside Python.

The Pyodide team maintains a build system that does exactly this. They patch the C extension layer to produce Wasm shared objects (.so files containing Wasm bytecode), bundle them with the interpreter, and load them on demand. From your script's perspective, import numpy works the same way it does on Linux. Under the hood, the import machinery is loading Wasm modules instead of native shared libraries.

The same approach extends to dozens of pure-C and Cython packages: Pandas, Pillow, scikit-learn, lxml, cryptography, even SQLAlchemy. The Pyodide package index lists hundreds of pre-built scientific libraries.

For pure-Python packages — anything on PyPI without a C extension — you don't need a pre-built Wasm wheel. Pyodide ships a tool called micropip that fetches them from PyPI at runtime:

import micropip
await micropip.install("requests")
import requests
# requests now usable, fetched and installed in-browser

The await is real — micropip.install is asynchronous, because it has to download the wheel over HTTP. The Pyodide runtime integrates Python's event loop with the browser's, so await works inside a runPython call.

What This Costs (and What It Buys)

Nothing comes free. Pyodide ships a 10-12 MB initial download. That's small for a desktop app, large for a web page. Browsers cache it aggressively, so the second visit is instant, but first-load latency is real. If you're embedding Pyodide on a marketing site, it's the wrong tool. If you're embedding it on a tool that users will spend minutes inside, it's barely noticeable after the first run.

Performance is slower than native CPython, but not catastrophically so. Pure Python code typically runs 2-3x slower than on a native interpreter. NumPy operations on large arrays run within 30-50% of native, because the heavy lifting happens inside Wasm-compiled C loops that hit memory directly. Tight numerical inner loops are the worst case; high-level glue code is the best.

Memory is sandboxed and capped. The browser's Wasm runtime gives the module a linear memory region (typically up to 4 GB on 64-bit Wasm, much less in practice). You're not going to load a 50 GB Pandas dataframe in a tab.

What you get in return:

  • Zero infrastructure. No backend, no Docker, no CI for an execution environment. The page is the runtime.
  • Privacy by default. Code never leaves the user's machine. For tools handling pasted secrets, regex against private logs, or scratch SQL against confidential data, that's not a feature, it's a hard requirement.
  • Offline support. Once cached, Pyodide runs with no network at all.
  • Distribution as static files. Anywhere you can serve HTML — GitHub Pages, S3, a USB stick — you can run Python.

Where It Makes Sense (and Where It Doesn't)

Browser-side Python is great for:

  • Education. Interactive Python tutorials, coding exercises, classroom demos. No "the lab server is down" excuses.
  • Documentation. Embed a runnable snippet next to the prose explaining it.
  • Data exploration tools. Light-to-medium analysis on datasets the user already has open.
  • Sandbox/scratch utilities. A Python online runner that you don't have to trust with your code.

It's the wrong fit for:

  • Long-running compute. Training a model, running a multi-hour ETL — keep that on a real server.
  • Anything needing OS-level access. No shelling out, no socket servers, no hardware peripherals beyond what the browser exposes.
  • Apps where 10 MB of initial download is unacceptable. A landing page should not depend on Pyodide.

The same logic applies to other in-browser language runtimes. JavaScript is native, so a JavaScript online runner needs no special runtime. TypeScript is one compilation step away — a TypeScript playground ships the TS compiler as a Wasm-or-JS bundle and evaluates the output. SQL gets the same treatment via SQL.js (SQLite compiled to Wasm), powering tools like a SQL online runner. The pattern is identical: take a runtime written in C, compile to Wasm, load on demand.

Talking Between Python and JavaScript

The bidirectional bridge is one of Pyodide's nicest features. Python objects are accessible from JavaScript, and JavaScript objects from Python:

const pyodide = await loadPyodide();

// Pass JS data into Python
pyodide.globals.set("user", { name: "Ada", age: 36 });
pyodide.runPython(`
  print(f"Hello {user.name}, you are {user.age}")
`);

// Get Python results back
const total = pyodide.runPython(`
  import math
  sum(math.sqrt(i) for i in range(100))
`);
console.log(total);

You can call browser APIs from Python via the js module:

from js import document, fetch
heading = document.createElement("h1")
heading.textContent = "Built by Python, rendered by the browser"
document.body.appendChild(heading)

response = await fetch("/api/data")
data = await response.json()

That last example is wild on first read. A Python script in a browser tab is using await to call fetch, the browser's own networking primitive, getting a real Response back, and parsing it. Two ecosystems that ignored each other for twenty years now share an event loop.

The Bigger Picture

Pyodide is the most polished example, but the same trick now applies to almost any language. Ruby has Ruby-on-Wasm. PHP has php-wasm. .NET has Blazor. Rust compiles to Wasm directly without an interpreter at all — the Rust toolchain produces Wasm as a first-class target. The browser is becoming a polyglot runtime.

The practical takeaway: when you build a developer tool that needs to execute user-supplied code, default to in-browser Wasm before reaching for a backend. The privacy story is better, the operational story is better, and after the first cache hit, the latency story is better too. Servers are still required for genuinely server-side work — multi-tenant compute, persistent state, integrations with paid APIs. They are no longer required for "let the user run their own code."

If you're sketching out the same kind of in-browser tool yourself and want to inspect what your bundles actually weigh, a Base64 encoder is handy for inlining small Wasm blobs into bookmarklets, and a JSON formatter helps when you're poking at Pyodide's package manifest.

The wonderful weirdness of all this: every line of CPython that took thirty years to write is now a 10 MB download, runnable from a USB stick on a Chromebook, with no install. The future arrived quietly, in a .wasm file.

FAQ

How big is the Pyodide download really?

The full Pyodide runtime (interpreter + standard library) is 10-12 MB compressed, expanding to ~40 MB in memory. Individual packages add to that — NumPy is ~6 MB compressed, Pandas is ~12 MB. The runtime is aggressively cached by browsers, so the second visit is instant. For tools where users spend a few minutes, the first-load cost is acceptable; for a marketing site, it's the wrong tool.

Can I run any Python package in Pyodide?

Pure-Python packages from PyPI work via micropip.install("package_name"). Packages with C extensions need pre-compiled Wasm wheels, which the Pyodide team maintains for hundreds of common packages (NumPy, Pandas, Pillow, scikit-learn, lxml, cryptography). Packages without Wasm wheels won't work — you can check the Pyodide package index for the supported list.

How fast is Pyodide compared to native CPython?

Pure Python code runs 2-3× slower than native. NumPy and other C-extension operations run within 30-50% of native speed because the heavy lifting happens inside Wasm-compiled C. Tight numerical inner loops are the worst case; high-level glue code is the best. For most interactive use cases, the difference is imperceptible.

Can Pyodide access files on the user's computer?

Only what the browser exposes — the File System Access API for picking files, IndexedDB for persistent storage in the tab's origin, or the standard <input type="file"> for uploads. Pyodide cannot directly read arbitrary paths like /home/user/data.csv because browsers sandbox file access. Files passed in from the user become a virtual filesystem inside Pyodide that Python can read normally.

Is Pyodide a security risk?

Less than running Python on a server, more than pure JavaScript. Wasm runs in the same sandbox as JavaScript, so a malicious package can't escape the browser. But user-supplied Python code can still execute arbitrary requests via js.fetch, modify the DOM, or exfiltrate data via XHR. For tools that run user-uploaded code, treat Pyodide like an iframe — sandbox the origin and limit network capabilities.

Why use Pyodide instead of just transpiling Python to JavaScript?

Because transpilers (Brython, Skulpt, Transcrypt) reimplement Python's semantics in JS — they don't run real CPython. Anything depending on the C extension layer (NumPy, Pandas, SciPy, Pillow) is impossible. Pyodide runs the actual CPython 3.11 source compiled to Wasm, so the entire scientific Python stack works. The 10 MB cost buys you full ecosystem compatibility.

Can I use Pyodide for production data analysis tools?

For tools where data must stay local (privacy, regulatory), absolutely. Pyodide-powered Jupyter alternatives like JupyterLite run thousands of users without server-side compute. The limits: memory caps (typically <2 GB per tab), no multi-user state, no GPU access. For light-to-medium analysis on user-owned data, it's an excellent fit. For heavy ETL or training, keep that on real servers.

What's the alternative if I need lower latency than Pyodide's first load?

Two options. WebAssembly System Interface (WASI) runtimes like wasmtime can run the same Wasm modules outside the browser at lower overhead, useful for serverless deployments. Lighter-weight Python alternatives like RustPython or MicroPython compile to smaller Wasm bundles (1-3 MB) but support fewer libraries. For interactive coding tutorials specifically, Skulpt's pure-JS Python implementation loads instantly but lacks NumPy.