Compiling to WASM: the toolchain from Rust and C
WebAssembly is a compile target rather than a language anyone writes by hand, and this lesson traces the actual pipeline that turns Rust or C source into a .wasm stack-machine module, why that pipeline is so much smoother for languages with a flat manual memory model, and where the friction still is for the ones without one.
Compiling to WASM: the toolchain from Rust and C
Two lessons ago you learned that "what language is WebAssembly?" is a category error — it's a destination, not a language, the same role JVM bytecode plays for Java. This lesson makes that concrete by walking the actual pipeline: what happens, mechanically, between writing fn add(a: i32, b: i32) -> i32 { a + b } in a text editor and having a .wasm file that a browser can load, validate, and run as the stack machine and linear memory you've already built up from first principles.
The general shape, before any specific language
Every mainstream path into WASM follows the same four stages, and it's worth seeing the shape once before attaching Rust or C names to it:
The frontend (rustc's parser, or clang's) does the work you'd expect any compiler to do — parsing, type-checking, borrow-checking for Rust — and lowers the result into an intermediate representation. For the whole LLVM-based family (Rust, C, C++, and others), that IR is LLVM IR: a typed, low-level, but still architecture-independent instruction set that isn't yet committed to any particular CPU or VM. The same LLVM IR that could become x86 machine code can instead be handed to a WASM backend, whose entire job is translating LLVM IR into the stack-machine bytecode from the first lesson — the i32.consts, i32.adds, and i32.loads you've already traced by hand. That's the moment your source code stops being "a program" in the abstract and becomes a concrete sequence of stack-machine instructions.
The output of that backend is a valid but usually unoptimized .wasm module, and the last stage — running it through wasm-opt, part of the Binaryen toolkit — shrinks and tightens it: removing dead code the compiler couldn't prove was unused, folding redundant instructions, and generally producing a smaller, faster module than the raw backend output. This stage is optional in the sense that skipping it still produces a working module, but every serious production build runs it.
The Rust path
For Rust, this pipeline is baked directly into the standard toolchain rather than bolted on. rustc itself has a WASM backend built in, invoked by targeting the triple wasm32-unknown-unknown:
rustc --target wasm32-unknown-unknown -O src/lib.rs -o out.wasmIn practice almost nobody calls rustc directly like that; the target gets added once (rustup target add wasm32-unknown-unknown) and then invoked through Cargo:
cargo build --target wasm32-unknown-unknown --releaseA minimal source file that produces something worth inspecting:
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}The two annotations matter mechanically, not just stylistically. #[no_mangle] tells the compiler not to apply its usual name-mangling scheme (which encodes type information into the symbol name for Rust-to-Rust linking) — without it, the exported function would show up in the .wasm file's export table under some compiler-generated name instead of the plain string "add". extern "C" fixes the calling convention to the plain, C-compatible one: arguments and return values passed as bare numbers, no hidden Rust-specific machinery. Together they guarantee the compiled module exports a function literally named add that takes two i32s and returns an i32 — exactly the shape a WASM function signature can express, and exactly what JavaScript on the other side of the boundary will call by that name.
wasm-pack wraps this whole flow — invoking cargo build with the right target, then running wasm-bindgen over the result. wasm-bindgen is a second, separate tool worth naming precisely: it doesn't change how your Rust compiles to WASM, it generates the JS glue code that lets you call your WASM exports with richer types than raw numbers — passing a Rust &str or a Vec<u8> from JS without hand-rolling the offset-and-length bookkeeping from the previous lesson yourself. That glue layer, and exactly what problem it's solving underneath the convenience, is the JS/WASM boundary lesson's subject.
The C/C++ path: Emscripten
C and C++ don't go through rustc's built-in target — they go through Emscripten, a toolchain built on top of Clang/LLVM specifically for compiling native C/C++ to WASM (and, historically, to asm.js before WASM existed). Its compiler driver, emcc, is a drop-in replacement for gcc or clang in your build:
emcc add.c -o add.wasm -O3Emscripten runs the same frontend → LLVM IR → WASM backend pipeline as any other Clang target, but it does one thing the raw pipeline doesn't: it patches over the fact that C code was written assuming a real operating system underneath it. A typical C program calls malloc, might open a file, might call printf, all of which assume a libc and, ultimately, system calls to a kernel. A browser sandbox has none of that — no filesystem, no process table, no write() syscall to a real terminal. Emscripten ships a compiled version of a C standard library (based on musl libc) and provides shims for the OS-level assumptions: a virtual, in-memory filesystem that file APIs read and write against instead of the real disk, and JS-side implementations standing in for the syscalls libc expects to make. Your C code still calls fopen and malloc exactly as written; Emscripten is what makes those calls resolve to something meaningful inside a sandbox that has no OS to ask.
What actually comes out of the backend
Whichever path produced it, a .wasm file has a small, fixed set of sections worth naming because they're literally what you're compiling into:
- Exported functions — the entry points the host (JS) is allowed to call, like
addabove. This is the export table the module's consumer sees. - Imported functions — the reverse: functions the module expects the host to supply, because the module itself can't do things like log to a console or read the system clock. WASM has no built-in I/O; any interaction with the outside world is an imported function the host implements and hands in at instantiation time.
- Linear memory — the byte array from the previous lesson, declared with an initial size (in 64KB pages) and an optional maximum.
- A data section — initial contents to drop into that memory before the module runs at all, most commonly string literals and other constants the compiled code expects to already be sitting at fixed offsets the first time it executes.
Every one of these is a direct answer to a question the earlier lessons raised: how does a module talk to the host (imports/exports), and where does data live before any code has run (the data section, populated into linear memory).
Why some languages fit this target more cleanly than others
Here's the part that isn't just toolchain trivia — it's a direct consequence of the memory model from the last lesson. C, C++, and Rust manage memory as a flat array plus an allocator (malloc, or Rust's ownership system deciding when to free). That model is linear memory already; compiling it means translating a scheme the source language already assumes into the stack-machine instructions that implement the same scheme. The runtime support code needed is tiny — an allocator, maybe a panic handler — because the language was never relying on anything WASM doesn't provide.
Garbage-collected languages — Go, C#, Java — have historically had a much rockier fit. Their whole execution model assumes a garbage collector is tracking every live object, walking the heap, and reclaiming what's unreachable. WASM's core has no concept of a managed object and no built-in GC — it only has linear memory (bytes) and four numeric types. So compiling a GC language to classic WASM meant compiling the entire language runtime, garbage collector included, down into WASM code that then treats linear memory as its own private heap, managing it in software exactly the way it would on native hardware. That runtime has to ship inside every module, and it's not small: a Go program's minimal "hello world" compiled to WASM has historically weighed in at megabytes, most of which is the Go runtime and GC rather than your actual logic. Compare that to a minimal Rust module, which can be a few hundred bytes, because Rust never needed to bring its own garbage collector along.
This gap is exactly why the newer WASM GC proposal matters, and why it's worth a forward-reference here rather than treating it as solved: it adds managed, garbage-collected reference types directly to the WASM core, so a language like Java or Kotlin can compile to instructions that use the engine's GC instead of shipping and running its own inside linear memory. The threads, SIMD, and the future lesson picks this up as one of the frontier changes to the model this lesson otherwise treats as fixed.
Binary size discipline
Because every byte of a .wasm module has to travel over the network before it can run, the toolchains give you real levers for keeping it small, and they're worth naming because they show up in almost every production Rust/WASM build:
- Dead-code elimination — both LLVM and
wasm-optcan prove that a function is never called and drop it entirely, so a large dependency doesn't cost you the size of code paths you never use. wee_alloc— a Rust allocator crate built specifically to be smaller (at some cost to raw allocation speed) than the default system allocator, popular in size-sensitive WASM builds where a few kilobytes of allocator code is a meaningful fraction of the total.wasm-opt -Oz— Binaryen's "optimize aggressively for size, even at some cost to speed" flag, as opposed to-O3's optimize-for-speed default. Which one you reach for depends on whether the module's bottleneck is download time or execution time.
Where this goes next
You now have the full mechanical story of how source becomes bytecode: a frontend and an IR you never see, a backend that emits the stack machine from lesson one, a data section that seeds the linear memory from lesson two, and an optimizer that trims the result. The one piece still missing is what happens at the seam — how JavaScript actually calls an exported function like add, how it gets a WebAssembly.Memory connected to a module's linear memory, and how tools like wasm-bindgen turn "pass two numbers" into "pass a string." The JS/WASM boundary is that seam, examined directly.
Go deeper
- Emscripten — Introducing Emscripten — The official overview of what Emscripten actually patches over when compiling C/C++ — the libc shims and virtual filesystem this lesson names.
- Rust and WebAssembly book — The canonical walkthrough of the wasm-pack / wasm-bindgen flow, including the JS glue code this lesson forward-references.
- webassembly.org — Developer's Guide — A language-by-language index of toolchains (Rust, C/C++, and others) for producing WASM, useful once you go beyond these two paths.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Name the four stages a Rust or C source file passes through on its way to a .wasm module, and which stage is where the stack-machine bytecode actually gets emitted.
- Why do #[no_mangle] and extern "C" both matter for a Rust function you intend to export to JS — what would go wrong without each?
- What specific problem does Emscripten solve that a plain Clang-to-WASM backend wouldn't, given that a browser sandbox has no filesystem or syscalls?
- List the four things a compiled .wasm module actually contains (beyond raw instructions), and what each one is for.
- Why does a minimal Rust module compile to a few hundred bytes while a minimal Go module has historically compiled to megabytes?
- What does the WASM GC proposal change about where a garbage collector for a language like Java would live?
- When would you reach for wasm-opt -Oz over -O3, and what's the actual tradeoff between them?