Skip to main content
Mentr by PaprlybyPaprly

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

The same engine powers four places: lesson examples, practice questions, Final Challenge projects and the full-screen compiler. Build it once, use it everywhere.

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

FileWhat it isSizeGzipped
pyodide.asm.wasmThe Python interpreter, compiled9.6 MB3.5 MB
python_stdlib.zipStandard library (math, random, json…)2.5 MB2.5 MB
pyodide.asm.mjsJavaScript glue for the WebAssembly1.25 MB0.26 MB
pyodide.mjsLoader18 KB7 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.

scripts/copy-pyodide.mjs (runs before every dev start and build)
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();
If our own copy ever fails to load, the worker automatically retries from jsDelivr's copy of the exact same version, and the status line says “Retrying from backup server”.

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

idleloadingreadyrunningready(or error → Retry)
  • 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

  1. 1
    pageClears the output and sends { type: "run", code, stdin } to the worker.
  2. 2
    workerIf the code imports a package such as numpy, installs it first (the status line shows progress).
  3. 3
    workerConnects Python's stdout and stderr to the page and turns on line buffering, so each print appears as it happens.
  4. 4
    workerRuns the code as a file called main.py in a fresh namespace, so variables from the previous run don't leak in.
  5. 5
    workerBatches output and sends it every 40 ms or 4 KB, whichever comes first. Streaming without flooding the page.
  6. 6
    pageAppends each chunk to the console: normal output in white, errors in red, typed input in green.
  7. 7
    workerSends done with success or error and the run time, e.g. "finished in 12 ms".
public/py-worker.js, simplified
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)

  1. Python calls input() and wants bytes
  2. Sets the flag to 0, posts “input-request”
  3. Sleeps on Atomics.wait()
  4. Wakes up, reads the line, Python continues
SharedArrayBuffer

Page (you)

  1. Shows a blinking answer box right after the prompt
  2. You type and press Enter
  3. Writes the text into shared memory
  4. Sets the flag to 1 and calls Atomics.notify()

The shared buffer has a tiny, fixed layout:

Int32 [0]
flag: 0 wait · 1 answered
Int32 [1]
byte length · −1 = end
bytes 8 … 65,543
the typed line, UTF-8 (64 KB max)
the worker side of input()
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.
The catch: browsers only allow 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

ProblemWhat we do
Infinite loopStopped after 15 s in the compiler (6 s in lessons). The message suggests looking for a loop that never ends.
Stop buttonTerminates 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 loopOutput is capped at 200,000 characters so the tab can't run out of memory drawing text.
Slow package installsCapped separately at 90 s before the program starts.
Out of memory / crashWorker errors are caught; the UI shows a clear message and a Retry.
Your filesCode 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.
ErrorHint we show
NameErrorA name is used before it's defined, or it's misspelt. Python is case-sensitive.
IndentationErrorCheck the spaces at the start of the line. Lines in the same block must line up exactly.
SyntaxErrorPython couldn't read this line. Look for a missing bracket, quote or colon.
TypeErrorTwo values of the wrong types were used together, like adding text to a number.
EOFErrorYour 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.

Online Python compiler▶ Run

print("Hi")

for i in range(3):

print(i)

Hi
0
1
2
Desktop: code and output side by side
CodeInputOutput

print("Hi")

Runs on your device▶ Run
Phone: tabs + thumb-reach Run
Desktop (≥ 1024 px)Phone
LayoutCode left, output rightTabs: Code · Input · Output
ResizeDrag the divider (30–75%). Saved; double-click resetsFull-width panes
RunButton in the header, or Ctrl/⌘ + EnterBig button in a bottom bar, inside the safe area
After RunOutput streams in on the rightSwitches to the Output tab automatically
ErrorLine highlighted in place“Line N” link jumps back to the Code tab
Typing13.5 px monospace16 px, so iOS Safari doesn't zoom in on focus
input()Answer box inline in the outputSame, with an “Enter = send” keyboard hint
The page height uses 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 (and turtle, 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 ModuleNotFoundError with 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

WhatValue
Python version3.14.2 (Pyodide 314.0.7)
Runtime download (first visit)≈ 13.4 MB raw · ≈ 6.3 MB gzip
Repeat visitsFrom browser cache (immutable, 1 year)
Run time limit15 s compiler · 6 s lessons (input wait not counted)
Output cap200,000 characters
Max line for input()64 KB
Upload limit.py files up to 200 KB
Optional packages available357 (loaded on first import)
Core codeWorker ≈ 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.