WebAssembly is often described as a way to run C or Rust in a browser. That is true, but it misses the more useful idea: WebAssembly, or Wasm, is a compact, typed compilation target with defined validation and execution semantics. A language frontend can compile to it, and a browser, server runtime, plugin host, edge platform, or embedded engine can instantiate the resulting module.
That portability does not mean a module is a self-contained portable application. Core Wasm has computation, linear memory, tables, globals, and functions, but no built-in files, sockets, clocks, DOM, or console. Every effect enters through an explicit host contract. Understanding that contract, including its copies and trust boundaries, is more important than memorizing the binary format.
From source language to a validated stack machine
Wasm binaries store typed instructions in a structured control-flow format. The human-readable WebAssembly Text format, WAT, exposes the same model using S-expressions. Most instructions consume operands from a conceptual stack and push results back. This function has no named temporary value: two local.get instructions push operands, then i32.mul pops both and pushes one i32 result.
(module
(func (export "square") (param i32) (result i32)
local.get 0
local.get 0
i32.mul))
Calling Wasm a stack machine describes its instruction semantics, not necessarily the generated hardware code. An engine decodes and validates the module, then an interpreter, baseline compiler, optimizing JIT, or ahead-of-time compiler maps stack values to registers and machine instructions. A production engine does not need to push every operand into a physical memory stack.
Validation happens before execution. The validator checks instruction types, function signatures, branch targets, structured blocks, initialized locals, index bounds in module metadata, and stack shape at reachable control-flow joins. For example, a function declared to return i32 cannot leave an f64 at its end. This single linear pass makes decoded code safe to compile without rediscovering arbitrary control flow.
Validation is not program verification. It proves that the module is structurally and type correct under Wasm rules; it does not prove that an index is meaningful, a calculation cannot overflow, or an algorithm terminates.
Note
The .wasm binary is the distribution format. WAT is an equivalent textual representation useful for learning, generated tests, inspection, and small hand-written modules; applications normally compile a source language rather than maintain large WAT files.
Modules, imports, exports, memory, and tables
A module is static code plus declarations. Instantiation pairs it with imports and creates runtime state such as memories, tables, and mutable globals. Multiple instances of the same compiled module can therefore have isolated state, while an embedder can deliberately provide shared imports.
The following complete WAT module imports one logging function, exports one memory, stores two functions in a table, and exposes operations over both. It can be assembled with wat2wasm math.wat -o math.wasm.
(module
(type $unary (func (param i32) (result i32)))
(import "env" "log_i32" (func $log_i32 (param i32)))
(memory (export "memory") 1 4)
(table (export "functions") 2 funcref)
(func $square (type $unary) (param $value i32) (result i32)
local.get $value
local.get $value
i32.mul)
(func $double (type $unary) (param $value i32) (result i32)
local.get $value
i32.const 2
i32.mul)
(elem (i32.const 0) $square $double)
(func (export "run")
(param $value i32) (param $which i32) (result i32)
(local $result i32)
local.get $value
local.get $which
call_indirect (type $unary)
local.tee $result
call $log_i32
local.get $result)
(func (export "sum_i32")
(param $ptr i32) (param $length i32) (result i32)
(local $end i32)
(local $sum i32)
local.get $ptr
local.get $length
i32.const 4
i32.mul
i32.add
local.set $end
block $done
loop $next
local.get $ptr
local.get $end
i32.ge_u
br_if $done
local.get $sum
local.get $ptr
i32.load
i32.add
local.set $sum
local.get $ptr
i32.const 4
i32.add
local.set $ptr
br $next
end
end
local.get $sum))
Memory is a contiguous byte array divided into 64 KiB pages. Here it starts at one page and may grow to four. Loads and stores use byte addresses; they trap when the accessed range falls outside memory. They do not know about arrays, strings, ownership, or object lifetimes. Those are conventions established by the source-language ABI.
A table is an indexed collection of references. It supports function pointers, virtual dispatch, callbacks, and dynamic linking patterns. call_indirect checks at runtime that the selected entry exists and has the expected $unary signature; index 2 in this two-entry table traps instead of jumping to an arbitrary address. Imports and exports are similarly typed and linked by module/name pairs during instantiation.
Crossing the JavaScript boundary and sharing memory
Browsers expose compilation and instantiation through the WebAssembly API. instantiateStreaming can validate and compile while bytes arrive, provided the server sends Content-Type: application/wasm. A fallback through response.arrayBuffer() is useful when a server cannot provide the correct MIME type, but it gives up streaming.
type MathExports = {
memory: WebAssembly.Memory;
run(value: number, which: number): number;
sum_i32(pointer: number, length: number): number;
};
const imports = {
env: {
log_i32(value: number): void {
console.log('Wasm result:', value);
},
},
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/math.wasm'),
imports,
);
const wasm = instance.exports as unknown as MathExports;
console.log(wasm.run(12, 0)); // square: 144
console.log(wasm.run(12, 1)); // double: 24
const values = new Int32Array([10, 20, 30, 40]);
const pointer = 0; // Wasm pointers are byte offsets into linear memory.
let memoryView = new Int32Array(wasm.memory.buffer);
memoryView.set(values, pointer / Int32Array.BYTES_PER_ELEMENT);
console.log(wasm.sum_i32(pointer, values.length)); // 100
wasm.memory.grow(1);
memoryView = new Int32Array(wasm.memory.buffer); // Recreate after growth.
The scalar calls convert JavaScript numbers to Wasm numeric parameters. The array call is different: memoryView.set copies four integers from a JavaScript-owned array into Wasm memory, then the function receives only a pointer and length. For large images, tensors, archives, or audio buffers, copying and marshaling can cost more than the computation being accelerated.
With non-shared memory, memory.grow replaces the exposed ArrayBuffer and detaches the old one, so cached typed-array views must be recreated. A real module also needs an allocation protocol. The host might call exported malloc and free, or a binding generator might manage temporary buffers. Both sides must agree on byte order, alignment, string encoding, ownership, and who releases memory. Wasm memory is little-endian, and a pointer from untrusted code must never be treated as valid without checking its range before a host import reads it.
Bindings generated by tools such as wasm-bindgen, Emscripten, or a component-model toolchain hide much of this ABI plumbing. They improve ergonomics, but they cannot make transfers free. Profile calls across the boundary, batch fine-grained operations, and keep hot loops and their data on one side where practical.
Warning
Linear memory is bounds checked, not logically typed. If two valid ranges overlap unexpectedly, or a stale pointer refers to memory reused by an allocator, the engine sees legal byte accesses. Source-language memory bugs can still corrupt module state even though they cannot directly address arbitrary host memory.
WASI, capabilities, and the component model
Core Wasm deliberately has no operating-system API. In a browser, JavaScript imports can expose selected DOM or network operations. Outside the browser, the WebAssembly System Interface, WASI, defines portable interfaces for capabilities such as streams, clocks, random data, and files. A runtime can grant a command access to a preopened directory without granting the whole filesystem, or omit network capabilities entirely.
This is a capability-oriented model: code receives handles and functions for the authority it needs instead of assuming ambient access. The distinction matters because a module compiled for a browser, a bare wasm32-unknown-unknown target, and a WASI target may all contain valid core Wasm while expecting incompatible imports. The binary instruction set is portable; the surrounding ABI and available capabilities determine whether the program is runnable.
| Target or contract | Typical host | What the module expects |
|---|---|---|
| Core Wasm with custom imports | Browser, plugin host, embedded engine | Application-specific functions and memories |
| JavaScript-oriented bindings | Browser or JavaScript server runtime | Generated JS glue, Web APIs, and a binding ABI |
| WASI command | Wasm runtime or container-like platform | Standardized WASI interfaces plus explicitly granted resources |
| Component | Component-aware runtime | WIT-defined typed interfaces, resources, and canonical ABI adapters |
The component model adds a higher-level contract above core modules. WebAssembly Interface Types, WIT, can describe records, variants, strings, lists, resources, and world-level imports and exports. Canonical ABI adapters lower these values into core memory and scalar calls, then lift results back into interface values. Components can compose modules written in different languages without making every pair agree on a private C-style ABI.
Component-model and WASI support still varies by language toolchain and runtime, so deployment should pin tested versions and interfaces. It is an interoperability layer, not a promise that any library can be recompiled without handling its platform assumptions.
A validated module begins in a sandbox: it cannot issue a system call or read host memory unless the embedder supplies a path. The sandbox is only as narrow as those imports. A host function that accepts an unchecked path, performs unrestricted HTTP requests, or returns secret data can create confused-deputy and exfiltration bugs. Limit capabilities, validate pointer/length pairs, bound CPU and memory, apply deadlines, and treat modules and toolchains as supply-chain inputs. Bounds checks also do not eliminate denial of service, logic bugs, side channels such as Spectre-class attacks, or vulnerabilities inside an unsafe-language heap.
Performance, tooling, and debugging
Wasm offers predictable low-level operations, compact binaries, SIMD, and explicit memory. It can be an excellent target for codecs, compression, cryptography, parsers, emulators, numerical kernels, and existing native libraries. Engines can cache compiled code, use tiered compilation for fast startup and later optimization, or compile ahead of time on servers.
It is not automatically faster than JavaScript or native code. Modern JavaScript JITs optimize stable, type-consistent code aggressively. Wasm still pays for compilation, boundary conversion, allocation, bounds checks, and sometimes data copies. Compared with native binaries it may lack a platform-specific instruction choice or mature library integration. Threads require shared memory and host support; in browsers they also require cross-origin isolation. SIMD and other features need runtime detection or deployment baselines.
Measure end-to-end latency and throughput with realistic inputs. Warm and cold startup are separate workloads, as are module download, compilation, instantiation, host-to-Wasm transfer, execution, and result transfer. A microbenchmark that places data in memory before timing can conceal the dominant production cost.
A small inspection loop catches many contract mistakes:
wat2wasm math.wat -o math.wasm
wasm-tools validate math.wasm
wasm-objdump -x math.wasm
wasm2wat math.wasm -o roundtrip.wat
WABT provides tools such as wat2wasm, wasm2wat, and wasm-objdump; wasm-tools validates, prints, composes, and inspects modules and components; Binaryen’s wasm-opt performs post-link optimization. Native-language toolchains can emit DWARF debug information, and browser developer tools or runtime debuggers can map instructions back to source when that metadata is preserved. Keep an unstripped artifact for diagnosis, produce deterministic release binaries, inspect imports before deployment, and profile both generated Wasm and host glue.
Do not choose Wasm merely because code is computational. Prefer the host language when work is dominated by DOM manipulation, database or network waits, many tiny host calls, or data already represented as rich host objects. Avoid it when bundle size and cold startup outweigh a short calculation, when the required runtime features are unavailable, or when a team cannot debug and patch the added native toolchain. For a trusted server service with no portability or isolation need, a native process may be simpler and faster. For an ordinary web form, JavaScript is almost always the clearer tool.
Takeaways
- WebAssembly is a validated, typed compilation target. Its stack semantics are compiled to real machine code; validation checks structure and types, not business correctness.
- A core module declares imports and exports and owns instance state. Linear memory stores untyped bytes, while tables provide checked indirect references and function dispatch.
- Host calls are an ABI boundary. Pointer/length conventions, allocation, encoding, memory growth, and copies must be designed and measured explicitly.
- WASI supplies portable capability-oriented system interfaces; the component model and WIT add rich cross-language contracts above core modules.
- Sandboxing removes ambient access, not all risk. Host imports, resource exhaustion, unsafe-language bugs, side channels, and supply-chain compromise remain relevant.
- Wasm performs best when substantial computation and data stay inside the module. Startup, JIT or AOT strategy, feature support, and boundary overhead decide real performance.
- Use WAT and binary tools to inspect contracts, preserve debug metadata, and choose Wasm for portability, isolation, or reusable low-level code rather than novelty.