Infraspec

WebAssembly as a Compilation Target

WebAssembly is a target a compiler aims at instead of a machine: source language, compiler, a `.wasm` module, and a runtime that validates it and then executes it — by interpreting, by compiling it on load, or by compiling it ahead of time.

The question

What actually happens between my C or Rust source and code running as WebAssembly?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A module: a binary file containing typed function definitions over a stack machine, a table of function references, a linear memory declared with a minimum and optional maximum size, a list of imports the host must supply, and a list of exports the host may call. Crucially it is *not* an executable and *not* a process — a module cannot do anything on its own, because every capability it has arrives as an import. The question the format exists to answer is: what does this code compute, expressed so that a host can verify it in one pass and then run it without trusting it.

What this phase may assume or do

A runtime may execute a module only after validation succeeds, and validation is a total, single-pass, type-checking procedure over the binary — every instruction's operand types are checked against the stack type at that point, every branch target must exist and be structurally enclosing, every memory access is to the module's own linear memory. A runtime is then entitled to assume the module is type-correct and control-flow-safe, which is what makes compiling it to native code without runtime checks on the control flow legal. It may not assume anything about the module's behaviour: an infinite loop, an out-of-bounds access within its own memory or a trap are all things the module may do, and the sandbox must survive them.

Key points

  • WebAssembly is a compilation target: the language frontend and middle-end are unchanged, and only the backend differs.
  • The output is a module — typed functions, a table, a linear memory, imports and exports — not an executable and not a process.
  • Every capability arrives as an import, so a module can reach exactly what its host chose to give it.
  • A runtime must validate a module before running it, and validation is a full single-pass type check rather than a formality.
  • How the module executes — interpreted, compiled at load, or compiled ahead of time — is entirely the runtime's choice.
  • Browser and standalone use pull the toolchain in different directions, and Emscripten and wasi-sdk correspond to those two ambitions.
  • Binary size is a first-class output metric because in the browser case the module is downloaded before anything runs.

The route from source to running code

typicalWhich execution strategy a runtime uses is not specified and varies widely: browsers stream-compile and tier up at run time, Wasmtime and Wasmer compile with Cranelift or LLVM either at load or ahead of time, WAMR and Wasm3 interpret on microcontrollers, and several runtimes support a precompiled artifact cache. Startup and throughput therefore differ by an order of magnitude between runtimes for the same module.

The shape is the ordinary compiler pipeline with a different last stop. A frontend for C, C++, Rust, Go, Zig, Swift or a dozen others compiles as usual through its own representations; the backend, instead of emitting x86-64 or AArch64 instructions, emits WebAssembly. The result is a .wasm module, and the machine it targets is one that does not exist in silicon.

What happens next is where WebAssembly differs from every target in this domain so far. The module is delivered — over a network, in a container image, as a plugin file — to a *runtime*, which validates it and only then executes it. That validation is not optional and not a checksum: it is a full type check of every instruction, and a module that fails it never runs at all.

The execution strategy is then the runtime's choice, and all the strategies from [[execution-strategies]] are in play. A small embedded runtime may interpret. A browser typically compiles the module to native code as it streams in, and may recompile hot functions with a better compiler afterwards. A server-side runtime may compile ahead of time and cache the machine code, so a later instantiation starts from native code with no compilation at all. Same module, three different answers, all conforming.

Source to WebAssembly to executiontypical
  1. Sourceyou write it
    C, C++, Rust, Zig, Go, Swift or another language with a Wasm backend.
  2. Compiler frontend and middle-endbuild time
    The language's own pipeline, unchanged — all of it happens exactly as for a native target.
    Everything the language checks and every optimization the middle-end performs.
  3. Wasm backendbuild time
    A .wasm module: typed functions over a stack machine, a table, a linear memory, imports and exports.
    A commitment to one target — but a target that is a specification rather than a chip.
    Native machine code, and with it direct access to the operating system. Every capability must now be imported.
  4. Toolchain post-processingbuild time
    The same module, optimized and shrunk.
    Size reduction and Wasm-level optimization — wasm-opt, dead-code stripping, name-section removal.
    Function names, unless kept deliberately; stack traces degrade accordingly.
  5. Deliveryload time
    A binary file, downloaded, embedded in an image, or loaded as a plugin.
    Portability. The same bytes run on every architecture with a conforming runtime.
  6. Validationload time
    The module, proven type-correct and structurally sound in a single pass.
    The guarantee that makes it safe to run untrusted code — see [[wasm-model]].
  7. Compilation or interpretationload time
    Native machine code, or bytecode in an interpreter, depending entirely on the runtime.
    Executable behavior. Which strategy is used is a runtime decision, not a property of the module.
  8. Instantiation and executionrun time
    An instance: the code plus its own linear memory, its table, and the imports the host supplied.
    State, and a boundary. The instance can reach exactly what the host gave it and nothing else.

Read it asTwo rows carry the difference from every other target in this domain. Validation, because no native target validates anything — the CPU executes whatever bytes it is given. And instantiation, because a native program gets a process with syscalls, while a Wasm instance gets exactly the imports the host chose to provide.

Two different ambitions, and the toolchains that follow

WebAssembly started as a way to run compiled code in a browser and has since become a general sandboxed execution target, and the two use cases pull the toolchain in different directions. In a browser, the module lives alongside JavaScript, calls into it and is called from it, and the interesting problems are the boundary between the two worlds and the size of the download. Outside a browser, the module is a unit of deployment — a plugin, a serverless function, an isolated extension in a database or a proxy — and the interesting problems are what capabilities the host grants and how fast an instance can start.

The system-interface question is what WASI addresses: a standardised set of imports for files, clocks, randomness and sockets, so that a module compiled once can run on any host implementing them. Because everything arrives as an import, the host decides what the module can reach — which is capability-based security by construction rather than by policy. See [[wasm-vs-native]] for what that costs.

The toolchain reflects the split. Emscripten targets the browser and brings a large compatibility layer — a POSIX-ish environment, a JavaScript glue layer, an OpenGL-to-WebGL translation. wasi-sdk and the Rust wasm32-wasip1 target aim at the standalone case with a much thinner runtime. Choosing between them is choosing which of the two ambitions you are pursuing, and using the wrong one produces either a module that will not run standalone or one that carries an unnecessary megabyte of browser glue.

The same module in four different hostsimplementation
HostTypical strategyImports availableWhat it optimizes for
BrowserStream-compile on download, tier up when hotJavaScript functions and Web APIs the page grantsTime to first execution, then throughput
Server-side runtimeAhead-of-time compile, cache the native codeWASI, plus whatever the embedder chooses to exposeInstantiation latency and isolation between tenants
Embedded / microcontrollerInterpret, or compile a subsetA minimal, hand-picked setMemory footprint above everything
Plugin host (proxy, database, editor)Compile once at load, reuse across instantiationsA narrow, purpose-built API surfaceBlast radius: an untrusted plugin must not reach the host

What the toolchain has to do that a native target does not

implementationWhich language features survive to WebAssembly cleanly is per-toolchain and per-proposal-set: threads require the shared-memory proposal and a host that enables it, exceptions require the exception-handling proposal, and garbage-collected languages needed the WasmGC proposal before they could avoid shipping their own collector inside the module. A feature that works in one runtime may be rejected as an unknown opcode by another with an older proposal set, which is the most common portability surprise in practice.

Several things a native backend gets for free have to be arranged explicitly. The C and C++ memory model expects a stack, a heap and taking addresses of locals; WebAssembly has typed locals that have no addresses at all, so the toolchain synthesises a "shadow stack" inside linear memory for anything whose address is taken. That is invisible in the source and visible in the generated code, and it is why address-taking is more expensive here than on a native target.

Dynamic linking and shared libraries are not the model either. A module is a unit, and composing modules is done through imports and exports or, more recently, through the component model — not through a dynamic loader resolving symbols in a shared address space. Toolchains that offer shared-library-like behaviour build it on top, and it does not behave like [[dynamic-linking]] in the details.

And binary size becomes a first-class output metric in a way it rarely is natively, because in the browser case the module is downloaded before anything runs. That is why wasm-opt exists as a post-processing step, why the standard library you link matters enormously, and why the name section — which carries function names for stack traces — is routinely stripped and then routinely missed the first time something crashes in production.

How it works

The steps, in the order the compiler takes them.

  • A language frontend and middle-end run exactly as they would for a native target, producing optimized IR.
  • A WebAssembly backend lowers that IR to typed stack-machine instructions with structured control flow, allocating a linear memory and synthesising a shadow stack for address-taken locals.
  • The toolchain emits a binary module with sections for types, imports, functions, tables, memory, exports, code and optional names.
  • Post-processing tools optimize at the Wasm level and strip what is not needed, most importantly the name section.
  • A host loads the module and validates it in one pass, type-checking every instruction against the operand stack and confirming every branch target is structurally enclosing.
  • The runtime interprets the module or compiles it to native code, at load time or ahead of time, at its own discretion.
  • The host instantiates the module, supplying every import, and calls an exported function; the instance runs with access to its own linear memory and nothing else.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A module compiled with a newer proposal enabled is rejected by an older runtime with an unhelpful "unknown opcode" or validation error, and the cause is a feature flag rather than a bug.
  • A stack trace from production is a list of numeric function indices, because the name section was stripped to save bytes and nobody kept a symbol map.
  • A module built for the browser with Emscripten will not run standalone, because it imports a large JavaScript glue surface that no non-browser host provides.
  • Performance is far below the native version and the cause is boundary crossings — thousands of small calls between host and module — rather than anything inside the compiled code.
  • Memory usage is much higher than expected because linear memory only ever grows within an instance, and a peak early in execution is never returned.
  • A build works with one runtime and traps in another because the two enable different proposal sets, most often around threads or exceptions.

When it helps

  • Running untrusted or third-party code — plugins, user-supplied extensions, multi-tenant functions — where the sandbox is the actual requirement.
  • Shipping compute-heavy code to a browser without rewriting it in JavaScript, which was the original motivation and remains the strongest case.
  • Distributing one artifact that runs on every architecture, where the alternative is a build matrix and a release process per platform.
  • Fast-starting isolated execution, where an instance can be created in microseconds against milliseconds for a container — see [[wasm-vs-native]].

When it hurts

  • Code dominated by calls across the host boundary, where the crossing cost swamps whatever the compiled code saves.
  • Workloads needing direct system access, since everything must be imported and anything the host does not provide simply does not exist.
  • Peak numeric performance, where bounds-checked linear memory and a restricted SIMD surface leave a measurable gap against a native build.

What it costs

Every one of these is paid by something.

  • Targeting a specified machine instead of a real one buys one artifact for every architecture, and pays with an abstraction layer between the code and the hardware that costs both performance and direct system access.
  • Mandatory validation buys the ability to run untrusted code safely, and pays with a load-time cost proportional to module size and with a hard rejection for any module using a proposal the runtime does not implement.
  • Capability-by-import buys precise control over what code can reach, and pays by making every host integration explicit work — nothing is available by default, including a clock.
  • Leaving the execution strategy to the runtime buys the same module running on a microcontroller interpreter and a tiering browser engine, and pays with performance that is a property of the host rather than of the artifact you tested.
  • Optimizing for binary size buys faster startup in the browser case, and pays with stripped names and much worse production diagnostics.

What else you could do

What a different compiler or language does instead, and when that is better.

  • A native binary per platform gives full performance and full system access, and costs a build matrix, a release process per architecture, and no sandbox — see [[cross-compilation]].
  • A container gives process-level isolation with a full operating system interface, and costs startup time measured in milliseconds and an image measured in megabytes.
  • A language-level VM such as the JVM or a JavaScript engine provides portability and sandboxing with a much larger runtime and a language commitment.
  • A dedicated scripting language embedded in the host — Lua, JavaScript, a DSL — is simpler to integrate and restricts you to that language, which is exactly what WebAssembly declines to do. See [[dsl]].
  • Running untrusted code in a separate process with seccomp or a hypervisor gives strong isolation at the operating system's granularity and cost.

See it for yourself

The flag, dump or tool that shows you this directly.

  • wasm-objdump -x module.wasm lists every section — types, imports, exports, memory, code — which is the fastest way to see what a module needs and offers.
  • wasm2wat module.wasm converts the binary to the readable text format; wat2wasm goes back, and the round trip is lossless.
  • wasm-opt -Oz shrinks a module; twiggy attributes size to functions and sections, which is how you find out where a megabyte went.
  • wasmtime compile produces a precompiled artifact so you can separate compilation cost from execution cost when measuring.
  • Browser DevTools disassemble a loaded module, set breakpoints in it, and — if DWARF was preserved — step through the original source.
  • clang --target=wasm32-wasi and cargo build --target wasm32-wasip1 produce modules directly, which is the shortest path to something you can inspect.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "WebAssembly is a language." It is a compilation target with a binary format and a readable text form. Almost nobody writes it by hand, and the text format exists mainly for tooling and teaching.
  • "WebAssembly replaces JavaScript." It runs alongside it. In a browser the module usually needs JavaScript to reach the DOM at all, and the boundary between them is a design problem rather than a migration path.
  • "A .wasm file is an executable." It is a module with no capabilities. Without a host supplying imports it cannot read a file, get the time or write output.
  • "It runs at native speed." It runs at a fraction of native speed that depends on the workload and the runtime, for reasons that are structural — see [[wasm-vs-native]].
  • "If it compiles to Wasm it will run anywhere." It will run on any host that implements the proposals it uses and provides the imports it declares. Both are real constraints.

Misconceptions

The claim, and what is actually true.

WebAssembly is only for the browser.
It began there and is now widely used server-side as a sandboxed unit of deployment: plugins, serverless functions, and isolated extensions inside proxies and databases.
Compiling to WebAssembly requires a special compiler.
It requires a backend. The frontend and middle-end are unchanged, which is why a language with an LLVM backend generally gets WebAssembly nearly for free.
Validation is a security scan.
It is a type check. It proves the module is well-formed and control-flow-safe; it says nothing about whether the module is malicious, which is what the capability model is for.
The text format is what you write and the binary is compiled from it.
Both are produced by compilers. The text format is a readable projection of the binary, used for tooling, tests and teaching.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

WebAssembly is a machine that does not exist, specified precisely enough that compilers can target it and every device can run it. You compile C or Rust to a .wasm file, hand it to a runtime — a browser, a server, a microcontroller — and the runtime checks it and then runs it, either by interpreting it or by turning it into real machine code first. The file itself can do nothing on its own: everything it is allowed to touch is handed to it.

practical

Pick the toolchain that matches the target: Emscripten for the browser with its glue layer, wasi-sdk or a Rust wasm32-wasip1 target for standalone. Then measure the boundary, not just the code — a workload that crosses between host and module thousands of times per second is usually dominated by the crossing. Keep a copy of the unstripped module or a symbol map before shipping the stripped one, because the first production stack trace of numeric indices is otherwise unreadable. And pin the runtime and its proposal set, since that is what decides whether your module runs at all.

advanced

The design decision worth studying is that WebAssembly is a target defined by what a host can *verify*, not by what a machine can execute. Almost every unusual feature follows from that: structured control flow instead of arbitrary jumps, because structure is what lets validation be a single pass; explicit typed imports, because a host must know exactly what it is granting; a linear memory that is one contiguous, bounds-checked region, because that is a property a compiler can enforce cheaply and a host can reason about. Compare this with a native target, where verification is impossible in principle — you cannot look at x86-64 bytes and prove anything about them, which is why native sandboxing needs hardware and an operating system. WebAssembly moves the safety argument from the hardware into the *format*, and everything it gives up relative to native code is the price of keeping that argument checkable.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

specThe module format, the validation rules and the instruction semantics are specified by the WebAssembly standard, so a valid module means the same thing on every conforming runtime. What is not specified is the execution strategy, the performance, or which post-1.0 proposals a given runtime implements — and the last of those is the most common source of a module that runs in one place and not another.
implementationRuntimes differ by an order of magnitude in both startup and throughput for the same module: browsers stream-compile and tier up, Wasmtime and Wasmer offer Cranelift and LLVM backends with different speed and compile-time profiles, and WAMR and Wasm3 interpret for microcontrollers. Any performance number is about a runtime and a version, never about WebAssembly.
typicalThe claim that browsers compile modules to native code on load describes current mainstream engines and is not required by anything. A conforming implementation could interpret indefinitely, and several small runtimes do exactly that, which is why the same module can be fast in one host and an order of magnitude slower in another.

If you were asked this in an interview

  • Walk me through what happens between a Rust source file and code executing as WebAssembly.
  • Why must a runtime validate a module, and what does validation actually prove?
  • A module runs in one runtime and is rejected by another. What are the likely causes?

Connections

OS & Networkingprogram-vs-process
Domains that do not exist yet
  • DevOps / Production Engineering — WebAssembly as a deployment artifact and the release process around it
    One artifact for every architecture changes what a build matrix, an artifact registry and a rollback look like. This lesson owns the compilation target; the deployment and operations story around it is owned there.