Other Languages in the Browser
WebAssembly made the browser a target for languages that had never considered it, and the results divide sharply into two groups. Some languages compile to WebAssembly directly, producing a module that contains your code and little else. Others ship an entire runtime — a garbage collector, a class loader, an interpreter — and run your code on top of it. The difference is two orders of magnitude in payload, and it explains almost every practical question about running a given language in a tab.
Prerequisites
- [ ] A specific reason for wanting a language other than JavaScript in the browser.
- [ ] A payload budget, because that is the axis everything here turns on.
- [ ] A toolchain for whichever language you are evaluating.
- [ ] A willingness to measure rather than to trust a table, including the one below.
Two groups, one deciding question
Ask whether the language needs a runtime at execution time. Rust, C, C++ and Zig do not: they compile to machine-like code with no managed memory, so a module contains the compiled functions and a small amount of support. Go, .NET, Java, Python and Ruby do: each needs a garbage collector and a runtime system, which has to be present in the browser before your first line executes.
That yields a payload floor per language which your application sits on top of. A Rust module for a small task is tens of kilobytes; a Python environment is megabytes before it has done anything at all.
What each language is actually good for
Rust is the default for new browser modules: the smallest self-contained output, the best tooling
through wasm-bindgen and wasm-pack, and no runtime. The cost is the learning curve.
C and C++ exist in the browser because enormous amounts of valuable code already exist in them. Emscripten’s job is making that code run unchanged, which it does remarkably well, and the payload is whatever the library was plus a little.
Zig produces output comparable to C with a modern build system and genuinely small modules, including the ability to build freestanding modules with no libc at all.
AssemblyScript compiles a TypeScript-like language directly to WebAssembly with a tiny runtime, which makes it the shortest path for a JavaScript team wanting a fast numeric kernel without learning a systems language.
Go compiles the whole runtime including its garbage collector and scheduler, producing megabytes; TinyGo produces far smaller modules by restricting the language and standard library, which is the practical option for the browser.
Python through Pyodide brings CPython and optionally the scientific stack, which is enormous and is also the point — nothing else gives a browser NumPy and pandas.
.NET ships its runtime and executes assemblies, as covered in running .NET in the browser.
Memory models, and why they leak into your design
The group a language belongs to changes more than payload; it changes how memory behaves at the boundary, and that shapes the interface you end up writing.
A compiled language without a garbage collector gives you explicit pointers into linear memory. The
host writes bytes at an address the module allocated, the module computes, and the host reads bytes back.
The discipline is the one described in
reading linear memory with typed arrays:
pointers are integers, lifetimes are yours to manage, and nothing moves unless you move it.
A hosted language’s objects live inside its own heap, managed by a collector that can move them. You cannot hand JavaScript a pointer to a Python list or a .NET string, because the address is not stable. Instead every such runtime provides a proxy layer: JavaScript receives a handle, and reading through it calls back into the runtime. That is convenient and considerably more expensive per access, and it means bulk data transfer needs an explicit copy into a flat buffer on one side or the other.
The practical consequence is that a hosted language’s boundary wants coarse calls with flat data — arrays of numbers, serialised strings — and punishes fine-grained object access from JavaScript. A compiled language’s boundary is the opposite: it is already flat, and the work is in agreeing on the layout. Designing the interface for the group you are in, rather than the one you are used to, avoids most of the disappointment people report about interop performance.
Reaching the DOM from each
No language touches the DOM directly; all of them go through JavaScript. What differs is how much machinery each ecosystem provides for it.
Rust has wasm-bindgen and web-sys, which generate typed bindings for essentially the entire web
platform — the most complete story of any language here. Emscripten provides EM_JS and EM_ASM for
embedding JavaScript, plus emulation layers for SDL and OpenGL that map onto browser APIs. AssemblyScript
declares imports and implements them in JavaScript, a deliberately thin and explicit arrangement. Go
exposes syscall/js, which is dynamic and reflective and correspondingly slower per call. Pyodide and
.NET each provide their own interop layer with automatic conversion between their object model and
JavaScript’s.
The performance of these layers varies more than people expect. A syscall/js call from Go is
substantially more expensive than a wasm-bindgen call from Rust, which matters if the boundary is hot
and not at all if it is crossed a few times per interaction.
Tooling maturity, which you will feel daily
The compiled group is not uniform in how pleasant it is to work with, and the differences show up immediately rather than after months.
Rust has the most complete story by a wide margin: wasm-pack produces a package with generated
JavaScript glue and TypeScript definitions, wasm-bindgen handles types across the boundary
automatically, and the error messages generally point at the real problem. Debugging works with DWARF and
the browser’s extension, and the size tooling — twiggy, wasm-opt — is well integrated.
Emscripten is mature in a different way: it is exceptionally good at making existing C and C++ projects work, with emulation for filesystems, SDL, OpenGL and pthreads, at the cost of a large surface of flags whose interactions are not always obvious. Its generated glue is bigger than Rust’s and does considerably more.
AssemblyScript’s tooling is deliberately minimal, which is both its appeal and its limitation: you write the imports and exports by hand, and there is no generated glue to misunderstand.
Zig’s build system is coherent and fast, with excellent cross-compilation, but the WebAssembly-specific ecosystem is thin — you will write the JavaScript side yourself and there is less prior art to copy.
TinyGo sits in the middle: a straightforward build command, a real subset of Go’s semantics, and a standard library with gaps that you discover by hitting them rather than by reading a list.
None of these is a reason to choose or reject a language on its own. They are a reason to budget time honestly: the same feature takes noticeably longer in a thin ecosystem, and that cost is real even when the output is smaller.
Payload, measured consistently
Every number below is a compressed module for a small program that does a little arithmetic and returns a result — the floor, not a realistic application:
| Language | Toolchain | Compressed floor |
|---|---|---|
| Rust | wasm-pack, size profile |
8–25 kB |
| Zig | zig build -Doptimize=ReleaseSmall |
2–10 kB |
| C | emcc -Oz -sSTANDALONE_WASM |
3–15 kB |
| AssemblyScript | asc -O3z |
3–12 kB |
| TinyGo | tinygo build -target wasm -no-debug |
25–80 kB |
| Go | GOOS=js GOARCH=wasm |
800 kB – 2.5 MB |
| .NET | dotnet publish |
1.2–2.5 MB |
| Python | Pyodide core | 6–12 MB |
The spread between the extremes is roughly a factor of a thousand. Two lessons follow: a language’s suitability for the browser is mostly decided by which group it is in, and within the compiled group the differences are small enough that other considerations should dominate.
Ecosystem access is the real reason people choose these
Payload arguments dominate the discussion, and they are not why anyone actually reaches for Python or .NET in a browser. The reason is libraries.
Pyodide exists because NumPy, pandas, scikit-learn and matplotlib exist, and reimplementing any of them in JavaScript is not a project anyone wants. A notebook interface, a teaching tool, a data exploration application — each of those is feasible in a browser only because the whole scientific stack compiles.
The same argument applies less dramatically elsewhere. A .NET application in the browser gets the .NET standard library and NuGet; a C++ module gets decades of numerical, geometric and media libraries; a Rust module gets crates.io, which for WebAssembly-relevant work is unusually strong.
So the honest framing is: the payload is what you pay for the ecosystem you need. If you need pandas, you need Pyodide and eight megabytes, and the question is whether the feature justifies it — often it does, behind an explicit user action. If you need a fast checksum, the ecosystem argument is absent and a compiled module is obviously right.
Beware of the middle case, where a familiar ecosystem is nice to have rather than necessary. That is where teams ship megabytes for convenience and regret it after the first performance review, because the equivalent module in a compiled language would have been a week of work and a hundredth of the size.
Startup, not just size
A large download is a one-time cost that caching largely removes. Startup is not: a runtime has to initialise on every page load, and for the hosted languages that is a real number.
Pyodide takes one to three seconds to initialise even from cache, because it is starting a Python interpreter and setting up its module system. .NET takes a few hundred milliseconds. TinyGo and the compiled languages take single-digit milliseconds, because there is nothing to initialise beyond instantiating the module.
That changes the shape of the feature you can build. A Python environment is fine behind an explicit action — “run this notebook”, “execute this script” — and wrong for something that must be ready when a page loads. A Rust module is ready before the user has finished reading the heading.
Threads, and which languages can use them
Threading requires cross-origin isolation, and then it requires the language’s runtime to support it.
Rust and C do, through wasm-bindgen-rayon and Emscripten’s pthreads. Go’s scheduler does not map onto
WebAssembly threads in the browser today. Pyodide supports it partially and with caveats. AssemblyScript
has no thread support in its runtime.
If your workload is parallel, that narrows the field considerably before any other consideration applies.
Gotchas and failure modes
- Choosing a language for the browser by its server-side reputation. Go’s excellent concurrency does not transfer; its payload does.
- Measuring uncompressed. Every figure here is compressed; uncompressed numbers are three times larger and mislead.
- Forgetting startup. A cached 8 MB Pyodide still takes seconds to initialise.
- Assuming the standard library works. Filesystem, network and process APIs are absent or emulated in every one of these languages.
- A chatty boundary in a language with expensive interop. Go’s
syscall/jsin a loop is a very different cost fromwasm-bindgenin a loop. - Shipping two runtimes. A page with both Pyodide and a .NET application has two garbage collectors and two multi-megabyte downloads.
Verifying a language choice
Build the same small task in two candidates before committing. It takes a morning and answers the questions a table cannot:
# the same function — parse, compute, return — in each candidate
wc -c dist/rust/engine_bg.wasm dist/tinygo/engine.wasm
brotli -q 11 -c dist/rust/engine_bg.wasm | wc -c
brotli -q 11 -c dist/tinygo/engine.wasm | wc -c
// and measure what the user experiences
const t0 = performance.now();
const m = await load();
const t1 = performance.now();
for (let i = 0; i < 1000; i++) m.run(sample);
console.log({ startupMs: t1 - t0, perCallUs: (performance.now() - t1) });
Startup and per-call cost together tell you far more than either alone, and both are properties of the language’s runtime rather than of your code.
Guides in this topic
- Running Python in the browser with Pyodide — the scientific stack in a tab.
- Compiling Go to Wasm with TinyGo — the practical Go option.
- AssemblyScript for TypeScript developers — the shortest path from JavaScript.
- Compiling Zig to WebAssembly — the smallest modules available.
- Running .NET in the browser — the runtime model without the framework.
- Comparing payload size across languages — one task, every toolchain, measured.
Frequently Asked Questions
Which language should I use if I have no constraints? Rust, for a new browser module. Smallest self-contained output, the best interop tooling, thread support, and an ecosystem that assumes WebAssembly is a target rather than tolerating it.
Does the browser cache help as much as it seems to? It helps enormously for repeat visits and not at all for the first one, which is the visit that decides whether someone stays. Quote both numbers whenever a payload is discussed, because a team that only tracks the warm number will be surprised by its analytics.
Can I mix languages in one page? Yes — separate modules, each with its own memory, coordinated from JavaScript. They cannot share memory or call each other directly without the component model, so keep the coordination in JavaScript.
Does the component model change the calculus? It makes cross-language composition practical, which today it largely is not. It does not change payload floors, since a language that needs a garbage collector still needs to ship one.
Is WebAssembly GC going to shrink the hosted languages? For languages that adopt it, substantially — the engine’s own collector replaces a shipped one. Kotlin, Dart and Java are moving this way; Go and .NET have deep runtime investments that make the transition slower.
How do I choose when two candidates are close? Build the smallest real thing in both, measure payload, startup and per-call cost, and then pick on the one that is not measurable — how quickly your team can write and maintain it. A module nobody on the team can debug is a liability regardless of how small it is.
What about languages not listed here? Kotlin, Dart, Swift, Ruby and several others have varying degrees of WebAssembly support, and the same question applies to each: does it ship a runtime? That single answer predicts payload, startup and interop cost more reliably than any project’s own benchmarks.
Can I ship a module compiled from a language my team does not know? You can, and you will regret it the first time it breaks. A compiled artifact is only as maintainable as the team’s ability to rebuild it, and a module nobody can modify becomes a permanent dependency on whoever wrote it. If the language is genuinely the right tool, invest in someone learning it rather than treating the module as a black box.
Related
- Compilation Pipelines & Toolchain Setup — building for each of these targets.
- Full-Stack Frameworks with Wasm — what some of these languages build on top.
- Wasm Optimization Flags & Size Reduction — making whichever you choose smaller.
If there is one heuristic to take from this page: ask what has to be downloaded before your first line of code executes. That number, more than any benchmark, decides whether a language belongs in your browser.
← Back to Production Wasm: Workloads & Deployment