Engineering · Mentr Learn
How we built a Python compiler that runs entirely in your browser
No server runs your code. Python itself is downloaded once, then runs on your own phone or laptop. This is the full architecture, step by step, with the real numbers and the real trade-offs.
0
servers running your code
Python 3.14.2
real CPython, compiled to WebAssembly
≈ 6.3 MB
first visit (gzip), then cached
1 codebase
for phones and desktops
Why
The problem we were solving
Our students learn Python in short lessons. Every lesson needs them to run code: in quick examples, in practice questions, in projects, and in a free-form compiler. Many of them are on a phone or a shared school laptop where they can't install anything.
The usual answer is a server that receives code, runs it in a container and sends the output back. We didn't want that:
- Running strangers' code on our servers is a security job of its own: sandboxing, resource limits, abuse.
- Cost grows with every Run click. A class of 40 students pressing Run every few seconds is a lot of containers.
- Latency. Every run is a network round trip, and
input()becomes a chat between browser and server.
So we flipped it: ship Python to the browser once, and run everything on the student's device.
Architecture
The big picture
There are only three moving parts. Our server never sees or executes the program; it only serves static files.
Inside one browser tab
Main thread · React UI
Editor
textarea + syntax colours
Output console
streams text as it arrives
PythonEngine
one per tab: start, run, stop, timeouts
postMessage + SharedArrayBuffer
Web Worker · background thread
Pyodide
CPython 3.14.2 compiled to WebAssembly
Python standard library
python_stdlib.zip, in-memory files
stdin / stdout / stderr
wired to the page
Downloaded once, then cached for a year
mentr.in/pyodide/v314.0.7/
our own copy of the runtime (first choice)
cdn.jsdelivr.net
backup copy, and optional packages like numpy
Step 1
Getting real Python into the browser
Browsers only run JavaScript and WebAssembly (a compact binary format that runs at near-native speed). We use Pyodide, an open-source project that compiles the official CPython interpreter to WebAssembly. It is not a re-implementation: it is the same Python, so behaviour and error messages match what students will see on a real computer.
These are the files a browser downloads the first time (sizes measured from our build):
| File | What it is | Size | Gzipped |
|---|---|---|---|
pyodide.asm.wasm | The Python interpreter, compiled | 9.6 MB | 3.5 MB |
python_stdlib.zip | Standard library (math, random, json…) | 2.5 MB | 2.5 MB |
pyodide.asm.mjs | JavaScript glue for the WebAssembly | 1.25 MB | 0.26 MB |
pyodide.mjs | Loader | 18 KB | 7 KB |
We self-host these files instead of loading them from a public CDN. A small build script copies them from the npm package into a versioned folder, public/pyodide/v314.0.7/. Because the version is in the path, the files never change, so we serve them with Cache-Control: immutable for a year. A second visit loads Python from the browser cache.
const installed = pkg.version; // from node_modules/pyodide
const expected = PYODIDE_VERSION; // from src/lib/python/config.ts
if (installed !== expected) throw new Error("version mismatch"); // build fails
copy(["pyodide.mjs", "pyodide.asm.mjs", "pyodide.asm.wasm",
"python_stdlib.zip", "pyodide-lock.json"], `public/pyodide/v${installed}`);
write("manifest.json", { version, sizes }); // used for the progress bar
removeOlderVersions();Step 2
A Web Worker keeps the page smooth
Python runs in a Web Worker: a background thread with no access to the page. If a student writes while True: pass, only the worker is busy; buttons, scrolling and typing keep working, and the Stop button can still be pressed.
On the page side, one PythonEngineobject per tab owns the worker. It is a small state machine that every screen subscribes to (through React's useSyncExternalStore):
- Preload: opening the compiler starts the download straight away, so Python is usually ready before the first Run.
- Real progress bar: the worker streams the three big files itself, counts bytes against the sizes in
manifest.json, and reports progress. Pyodide then loads them instantly from the HTTP cache. - Live status:the header shows “Downloading Python · 42%”, “Starting Python”, then “Python 3.14.2 · runs in your browser”.
Step 3
What happens when you press Run
- 1pageClears the output and sends { type: "run", code, stdin } to the worker.
- 2workerIf the code imports a package such as numpy, installs it first (the status line shows progress).
- 3workerConnects Python's stdout and stderr to the page and turns on line buffering, so each print appears as it happens.
- 4workerRuns the code as a file called main.py in a fresh namespace, so variables from the previous run don't leak in.
- 5workerBatches output and sends it every 40 ms or 4 KB, whichever comes first. Streaming without flooding the page.
- 6pageAppends each chunk to the console: normal output in white, errors in red, typed input in green.
- 7workerSends done with success or error and the run time, e.g. "finished in 12 ms".
py.setStdout({ write: (bytes) => emit("stdout", decode(bytes)) });
py.setStderr({ write: (bytes) => emit("stderr", decode(bytes)) });
py.runPython("import sys; sys.stdout.reconfigure(line_buffering=True)");
const ns = py.globals.get("dict")(); // fresh globals every run
ns.set("__name__", "__main__");
await py.runPythonAsync(code, { globals: ns, filename: "main.py" });Step 4 · the hard part
Making input() feel like a real terminal
Beginners' programs are full of name = input("Your name? "). In a terminal, Python pauses until you type. But a browser never lets JavaScript pause and wait; everything is asynchronous. So how does Python, running inside JavaScript, stop in the middle of a line and wait for the keyboard?
The answer: the worker does block, on purpose, using a shared piece of memory that both threads can see.
Worker (Python)
- Python calls input() and wants bytes
- Sets the flag to 0, posts “input-request”
- Sleeps on
Atomics.wait() - Wakes up, reads the line, Python continues
Page (you)
- Shows a blinking answer box right after the prompt
- You type and press Enter
- Writes the text into shared memory
- Sets the flag to 1 and calls
Atomics.notify()
The shared buffer has a tiny, fixed layout:
flag: 0 wait · 1 answered
byte length · −1 = end
the typed line, UTF-8 (64 KB max)
function waitForLine(id, sab) {
const ctrl = new Int32Array(sab, 0, 2);
Atomics.store(ctrl, 0, 0); // "I'm waiting"
post({ type: "input-request", id });
while (Atomics.load(ctrl, 0) === 0) Atomics.wait(ctrl, 0, 0);
const len = Atomics.load(ctrl, 1);
if (len < 0) return null; // Ctrl+D → EOFError in Python
return new TextDecoder().decode(new Uint8Array(sab, 8, len).slice());
}Three details that matter:
- One line per read. We give Python a low-level reader that returns exactly one typed line, like a terminal. A higher-level option kept asking for more lines before returning the first.
- Thinking time is free. The run timer is paused while the program waits for you, and restarts when you press Enter.
- Ctrl+D in an empty answer box sends “end of input”, which Python reports as
EOFError, as in a real terminal.
SharedArrayBuffer on pages that are cross-origin isolated. We send two headers only on the pages that run code interactively (the compiler and the course projects): Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and mark our runtime files Cross-Origin-Resource-Policy: same-origin. Because isolation applies to a fresh page load, the “Open in compiler” buttons do a full navigation.Fallback: if a browser can't isolate the page, the compiler quietly switches to an Input box: you type every answer up front, one per line, and each input()takes the next line. The output still shows what was “typed” in green so it reads like a terminal session.
Step 5
Safety nets
| Problem | What we do |
|---|---|
| Infinite loop | Stopped after 15 s in the compiler (6 s in lessons). The message suggests looking for a loop that never ends. |
| Stop button | Terminates the worker instantly; it's the only reliable way to stop WebAssembly mid-loop. A fresh worker warms up from cache in the background. |
| print() in a runaway loop | Output is capped at 200,000 characters so the tab can't run out of memory drawing text. |
| Slow package installs | Capped separately at 90 s before the program starts. |
| Out of memory / crash | Worker errors are caught; the UI shows a clear message and a Retry. |
| Your files | Code runs inside the browser's WebAssembly sandbox with its own in-memory file system. It can't read files on your computer, and that memory is gone when the page closes. |
15 s
run limit in the compiler
200,000 chars
output cap
New worker
after every Stop or timeout
Step 6
Errors a beginner can actually read
A raw Pyodide traceback includes many lines of interpreter internals. The worker cleans it up before it reaches the page:
- Keeps only the part that starts at
File "main.py", the student's own code. - Finds the line number, highlights that line in the editor, and shows a “Line 4 in main.py” link that jumps to it.
- Adds a plain-English hint for the most common beginner errors.
- The full traceback is still one click away for curious students.
| Error | Hint we show |
|---|---|
NameError | A name is used before it's defined, or it's misspelt. Python is case-sensitive. |
IndentationError | Check the spaces at the start of the line. Lines in the same block must line up exactly. |
SyntaxError | Python couldn't read this line. Look for a missing bracket, quote or colon. |
TypeError | Two values of the wrong types were used together, like adding text to a number. |
EOFError | Your program called input() but there was nothing left to read. |
Step 7
A small editor, built for students
We didn't pull in a heavyweight code-editor library. The editor is a normal <textarea>with transparent text, laid exactly over a coloured copy of the same code. You type into the real textarea, so the phone's own keyboard, selection, copy-paste and accessibility all work, and you see the coloured layer underneath.
Two layers, one grid cell
highlighted <pre> below, transparent <textarea> on top
Python-aware keys
Tab = 4 spaces · auto-indent after a colon · Ctrl/⌘ + Enter runs
Syntax colours come from a single regular expression that recognises comments, strings, keywords, common built-ins and numbers. It is deliberately simple: fast enough to re-colour on every keystroke.
Step 8
One compiler, two very different screens
Same component, same engine. Only the layout changes, using CSS breakpoints. No separate mobile app or mobile version.
print("Hi")
for i in range(3):
print(i)
0
1
2
print("Hi")
| Desktop (≥ 1024 px) | Phone | |
|---|---|---|
| Layout | Code left, output right | Tabs: Code · Input · Output |
| Resize | Drag the divider (30–75%). Saved; double-click resets | Full-width panes |
| Run | Button in the header, or Ctrl/⌘ + Enter | Big button in a bottom bar, inside the safe area |
| After Run | Output streams in on the right | Switches to the Output tab automatically |
| Error | Line highlighted in place | “Line N” link jumps back to the Code tab |
| Typing | 13.5 px monospace | 16 px, so iOS Safari doesn't zoom in on focus |
| input() | Answer box inline in the output | Same, with an “Enter = send” keyboard hint |
100dvh(dynamic viewport height), so the Run bar stays visible when the phone's browser bars slide in and out.Step 9
Saving your work without an account
- Code and input are autosaved to the browser's localStorage300 ms after you stop typing. Reload and it's still there. Nothing is uploaded.
- Examples menu loads ready programs (hello world, input(), loops, if/elif, lists and functions, random dice, star pattern).
- Open a .py file (up to 200 KB), Download main.py, and Copy: everything happens locally.
- Inside the course, lessons can hand code to the compiler with an “Open in compiler” button.
Step 10
numpy and friends, on demand
The standard library ships with the runtime. For anything else, the compiler reads the program's import lines before running and asks Pyodide to install matching packages (loadPackagesFromImports). The Pyodide distribution we use lists 357 packages, including numpy. They download from jsDelivr only the first time a program imports them, then the browser caches them.
Lesson exercises skip this step on purpose, so they start instantly.
Honesty
What it can't do (yet)
- First load is a real download(about 6.3 MB compressed). On a slow connection the first Run takes a few seconds; after that it's cached.
- No desktop windows.
tkinter(andturtle, which is built on it) need a window system that doesn't exist inside a browser tab. - Only packages Pyodide has built for WebAssembly. If a library isn't in that list, you get
ModuleNotFoundErrorwith an explanation. - Interactive input() needs a modern browser that supports cross-origin isolation; older ones get the Input box instead.
- Long jobs are cut off at 15 seconds. This is a learning tool, not a place to train models.
- Your code lives on one device. Clearing site data, or switching to another phone, starts fresh.
Summary
The numbers, in one place
| What | Value |
|---|---|
| Python version | 3.14.2 (Pyodide 314.0.7) |
| Runtime download (first visit) | ≈ 13.4 MB raw · ≈ 6.3 MB gzip |
| Repeat visits | From browser cache (immutable, 1 year) |
| Run time limit | 15 s compiler · 6 s lessons (input wait not counted) |
| Output cap | 200,000 characters |
| Max line for input() | 64 KB |
| Upload limit | .py files up to 200 KB |
| Optional packages available | 357 (loaded on first import) |
| Core code | Worker ≈ 215 lines · engine ≈ 360 lines · compiler UI ≈ 600 lines |
Stack
Next.js + React + TypeScript · Pyodide (CPython → WebAssembly) · Web Worker · SharedArrayBuffer + Atomics
Server's job
Serve static files with the right cache and isolation headers. That's it.
Try it yourself
Free, no sign-up. Open it on your phone and on your laptop. It's the same compiler.
