How Python and JavaScript run on the same register VM
On 13 September 2026 Zipp gained a Python 3 frontend that compiles straight to the bytecode its JavaScript engine already executes. No second interpreter, no transpiling to JavaScript source. This article explains what in the VM's design made that possible, how the frontend is built, and what it currently costs.
The VM was already language-agnostic
Zipp's VM executes FuncProto objects: a flat vector of three-address instructions over a fixed register file, with an explicit frame stack in the heap. Calls, exceptions, generators, closures and module state are runtime data structures. The bytecode does not know what a var is or that this exists; the JavaScript compiler lowers those concepts into registers, cells and property operations. Any language whose semantics can be expressed as registers, cells, property access on heap objects and a call convention can target it.
Python fits. Its scoping is static (a symbol table decides locals, cells and globals at compile time, exactly as CPython does), its objects are dictionaries with a method-resolution order, and its calls bind positional and keyword arguments to a signature. All three map onto things the VM already has.
The pipeline
- Parse with Zipp's own parser
zipp-pyparse, Zipp's own lexer and recursive-descent parser for Python 3.13 syntax, produces the syntax tree, laid out in one arena per module. No code from another Python implementation is involved. Compile-time caps guard it: 1 MiB per module, 2^20 tokens, 200 nested brackets, 100 indentation levels, 96 nested expressions, 32,768 functions per program. - Build the symbol table
symtable.rsapplies CPython's scoping rules: which names are local, which are free, which are cells, which are module globals, whatnonlocalandglobaldo, how comprehensions and class bodies scope. - Emit register bytecode
emitter.rs,stmts.rsandexprs.rslower each code object to aFuncProtowith a fixed ABI. Register 0 is the Python function object; register 1 is the array of bound positional values produced by the runtime'sbind. Locals are registers, captured locals are cell objects, free variables are read off the function's cells, and module globals are aMap. - Link against a fixed runtime
Python's object model (
core.js,types.js,builtins.js,stdlib.js,entry.js) is JavaScript that the VM compiles once per program. Guest Python never sees the host's JavaScript globals; the runtime is an implementation detail, not an interop surface.
Because a Python call is a direct VM Call, Python recursion lives on the VM's explicit frame stack and ends in a catchable RecursionError. Because Python exceptions are VM exceptions, try / except / finally and raise ... from compile to the same unwinding the JavaScript compiler uses. Generators use the VM's suspendable frames, so send, throw, close and yield from came almost for free.
What had to be built anyway
The VM gave the frontend control flow and memory; it did not give it Python. The runtime implements arbitrary-precision integers (on the VM's BigInt), the full str and bytes method sets, the container types with CPython's semantics, C3 method resolution, descriptors, properties, __slots__, metaclasses, __init_subclass__ and __set_name__, the full match statement, CPython-style tracebacks including the [Previous line repeated N more times] collapsing, and a standard-library subset large enough to run real programs: math, random, json, re, itertools, functools, collections, dataclasses, enum, typing, struct, hashlib, pathlib, argparse and more.
Projects are folders. Every .py file is a module or package by folder, with CPython-like import cycles, and every file is readable through open(), os.path and pathlib from a virtual filesystem capped at 8 MiB per file and 64 MiB in total. Writes are reported to the host as versioned changes; the CLI writes them to disk, the browser only reports them.
Validation: CPython is the oracle
Every program in tests/python_corpus runs under a local CPython and under Zipp, and the standard output must match byte for byte. The CPython outputs are committed, so continuous integration needs no CPython. The Torch subset has its own fixtures against PyTorch: the training example reproduces ten rounded losses from 0.085254 to 0.010812, and a GPU training fixture compares five steps against CPU PyTorch 2.11. These are functional checks and the documentation refuses to call them anything else.
What it costs today
When this article was first written, Python on Zipp was an interpreter over a JavaScript-shaped VM with every integer a BigInt, many times slower than CPython. The dedicated Python bytecodes it named as the next step have since landed, together with much more: fused Python-only instructions with per-VM inline caches, PyCall direct calls, integers within ±2^46 held in the value word, dicts, sets and instances in native storage with hidden-class layouts, and a Python JIT tier for hot loops on the command line. Over the 36 programs of the benchmark suite Zipp now runs at about 1.75× CPython 3.13's time with that JIT, from about 22× at the start of the work, and five of the programs beat CPython. WebAssembly and budgeted embedders stay interpreted.
The feature gaps are listed rather than hidden: no async / await, no type-parameter syntax, no except*, no threads, no sockets, and input() raises EOFError.
Interop, deliberately limited
Since both languages share a VM, calling across is cheap to offer and dangerous to offer carelessly. The opt-in python-js-interop feature exposes javascript.eval(source) and copies data across: numbers, strings, booleans, BigInts, lists and plain dicts, with None as null, capped at 10,000 visited values, depth 32 and 32,768 code units of source. Functions, accessors, proxies and cycles are rejected. The documentation states the important part in bold: this shares VM globals and is not an isolation boundary between mutually untrusted languages. Neither published WebAssembly archive enables it.
Why do it this way
The alternative designs were a second interpreter (two GCs, two limit systems, two host surfaces, two audits) or transpiling Python to JavaScript source (semantics drift, no tracebacks, no way to keep Python off the host globals). Compiling to the shared bytecode meant the browser sandbox's instruction budget, heap ceiling, host-call channel and Worker deadline apply to Python unchanged, the same Engine class runs both, and the WebAssembly module with Python is 43% larger than the one without rather than twice the size. It also meant the day the Python frontend landed, it already had a garbage collector, a conformance-tested string implementation and an audited host boundary.