WebAssembly Versus Native
One artifact everywhere, a sandbox by construction and microsecond startup, against a measurable performance gap with structural causes: bounds-checked linear memory, no direct system calls, and a feature surface that depends on which proposals the host implements.
Should I ship this as WebAssembly or as a native binary, and what am I actually giving up?
Two artifacts with different contracts. A native binary is machine code for one architecture and one ABI, given a process with ambient authority over everything the operating system permits, and verified by nobody. A Wasm module is a validated, architecture-independent description of computation, given exactly the capabilities its host chose to import, and executed by a strategy the host picks. The comparison is not "which is faster" but "which contract do you want", and every entry in the table below follows from the contract rather than from implementation quality.
A host may run a native binary only by trusting it — nothing about it is checkable in advance, which is why isolation for native code has to come from the operating system or the hardware. A host may run a Wasm module only after validation succeeds, and it is then entitled to assume type correctness and control-flow safety but nothing about resource use: it must impose its own limits on time and memory because the format guarantees neither. The performance comparison inherits the same asymmetry: a native compiler may assume any access is to a valid address that the program is responsible for, while a Wasm runtime must guarantee every access falls inside the module's memory — and that difference in what may be assumed, not any difference in optimizer quality, is where a large part of the gap comes from.
Key points
- Portability, sandboxing and startup favour WebAssembly structurally; peak optimization and host integration favour native structurally.
- The performance gap has four causes: bounds-checked memory, boundary crossings and copies, a restricted feature surface, and a compiler that may be optimizing for speed of compilation.
- Only the fourth of those closes with better implementations; the first three are the price of the guarantees.
- A Wasm instance starts in microseconds because it is an allocation and some import binding, not an operating-system object with a loader run.
- Binary size comparisons are really about who supplies the runtime, and the answer differs per language and per host.
- The decision is usually settled by a hard requirement — untrusted code, instance count, architecture count — rather than by a benchmark.
- A trusted native core with an untrusted WebAssembly extension surface is a common and well-motivated middle answer.
The comparison, dimension by dimension
Six dimensions decide this choice in practice, and they do not all point the same way — which is the reason the choice exists. Portability, sandboxing and startup favour WebAssembly for structural reasons. Peak optimization, host integration and, often, binary size favour native for equally structural reasons.
The important discipline is to keep the causes separate from the numbers. Any specific performance figure is a measurement of one program on one runtime at one version, and the numbers move. The *causes* are stable: bounds-checked memory, no direct system calls, a restricted feature surface, and a boundary that copies. Reasoning from causes survives the next release; reasoning from a benchmark does not.
| Dimension | WebAssembly | Native | Why |
|---|---|---|---|
| Portability | One artifact runs on every architecture with a conforming runtime | One artifact per architecture and ABI, from a build matrix | The target is a specification rather than a chip, so architecture is the runtime's problem |
| Sandboxing | By construction: no ambient authority, and no instruction can name an address outside the module | By the operating system, if configured: processes, seccomp, containers, hypervisors | Nothing about machine code is checkable in advance, so native isolation must come from outside the code |
| Startupimplementation | Microseconds to instantiate from a precompiled module; milliseconds if the host must compile it first | Process creation plus dynamic linking and loader work — typically milliseconds | An instance is a memory allocation and some import binding; a process is an operating-system object with a loader run |
| Peak optimizationtypical | A measurable gap on compute-heavy code, from bounds checks, a restricted SIMD surface and less aggressive backends | Everything the toolchain can do, including full auto-vectorization and target-specific instructions | The runtime must guarantee memory safety and often compiles under a time budget the ahead-of-time compiler does not have |
| Host integration | Everything crosses an explicit boundary, and anything larger than a scalar is copied | Direct calls, shared pointers, and any system call the process is permitted | The boundary is the sandbox. Removing the copy would mean removing the isolation |
| Binary size | Often smaller for a self-contained artifact, since the runtime is the host's; larger if a language ships its own runtime inside the module | Small if it dynamically links the system libraries; large if it is a static binary with a runtime baked in | The question is really "who supplies the runtime", and it is answered differently in each case |
Where the performance gap actually comes from
Being precise about this matters, because "Wasm is slower" invites the conclusion that a better runtime would close the gap, and only part of it would. There are four contributions and they have different fixes.
First, bounds-checked linear memory. Every load and store must be inside the module's memory, and even the cheap implementation — reserving a large guard region so the hardware traps instead of the compiler comparing — constrains what the optimizer may do around memory accesses. That is the price of the guarantee, and it is structural: removing it removes the sandbox.
Second, no direct system calls. Anything the module needs from the outside world is an import, which means a boundary crossing and, for anything bigger than a scalar, a copy into or out of linear memory. For I/O-heavy or chatty workloads this dominates everything else, and no compiler improvement touches it — the fix is restructuring the interface into fewer, coarser calls.
Third, the feature surface. SIMD is available through a proposal with a fixed 128-bit width, so code that would use wider vectors natively gets narrower ones or none. Threads require the shared-memory proposal and a host that enables it. Tail calls, exceptions and reference types are each separate proposals. What you can express depends on which of them your host implements, and the fallback is usually much slower rather than absent.
Fourth, the compiler. Runtimes that compile at load time use fast backends — Cranelift is designed for compile speed — and produce worse code than an ahead-of-time LLVM or GCC build. This one *is* an implementation matter and it does close: an ahead-of-time Wasm compilation with an LLVM backend narrows the gap considerably, at the cost of the fast startup that motivated the fast backend.
Choosing, and the questions that actually decide it
The decision is usually settled by a requirement rather than by a benchmark. If you must run code you did not write, the sandbox is the requirement and the performance gap is the price — the alternative is not a faster native build, it is a container or a VM with its own costs. If you must start thousands of short-lived isolated executions, instantiation latency dominates and nothing native competes. If you must ship one artifact to many architectures, the build matrix you avoid is the whole argument.
Conversely, if the code is trusted, runs on infrastructure you control, is dominated by system calls or by numeric kernels, and targets one architecture, then almost every dimension in the table points to native and the portability argument has nobody to convince.
The mixed answer is common and worth naming: compile the compute-heavy trusted core natively and expose the untrusted extension surface as WebAssembly. Proxies, databases and editors have converged on exactly that shape, because it puts the sandbox precisely where untrusted code is and nowhere else. That is the same reasoning as [[dsl-tooling-cost]] applied to isolation: the boundary should be where the risk is, not everywhere.
| If the requirement is… | Choose | Because |
|---|---|---|
| Running code from a third party | WebAssembly | The sandbox is the requirement; the gap is what it costs |
| Thousands of short-lived isolated runs | WebAssembly | Instantiation is microseconds against milliseconds for a process |
| One artifact for many architectures | WebAssembly | The build matrix disappears entirely |
| Peak numeric throughput on known hardware | Native | Full vectorization and target-specific instructions are available |
| Heavy system-call or I/O interaction | Native | Every crossing is a boundary and a copy in the other model |
| A trusted core plus untrusted plugins | Both | Put the boundary where the untrusted code is, and nowhere else |
How it works
The steps, in the order the compiler takes them.
- A native binary is loaded by the operating system into a process with ambient authority; isolation, if any, comes from process boundaries, seccomp, containers or a hypervisor.
- A Wasm module is validated, then compiled or interpreted, then instantiated with exactly the imports the host supplies and its own linear memory.
- Every module memory access is guaranteed to fall inside that memory, implemented either as an explicit compare or by a reserved guard region that makes the hardware trap.
- Every interaction with the outside world goes through an imported function, marshalling scalars directly and copying anything larger through linear memory.
- The runtime imposes its own limits on execution time and memory growth — fuel, epoch interruption, timeouts, memory caps — because the format guarantees neither.
- A host that precompiles modules ahead of time stores the native code and skips compilation at instantiation, which is what makes microsecond startup achievable.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A migration to WebAssembly is undertaken for performance, and the result is slower, because the workload was dominated by system calls rather than by compute.
- A benchmark shows a small gap on one kernel and a large one on another, and a single averaged number is reported that predicts neither.
- Startup is measured on a runtime that compiles at load and reported as WebAssembly's startup cost, when a precompiled artifact would have been two orders of magnitude faster.
- A module built with SIMD and threads enabled fails validation in production because the deployed runtime implements neither, and the failure appears at load rather than as slower execution.
- A native binary is chosen for a plugin system and a third-party plugin takes down the host process, which is the failure the sandbox existed to prevent.
- Memory usage across many instances is far higher than expected, because each instance holds its peak linear memory for its lifetime and none of it is returned.
When it helps
- Making an architecture decision on causes rather than on a benchmark that will be stale next quarter.
- Explaining to stakeholders why a sandboxed execution model costs performance, in terms that are structural rather than apologetic.
- Designing a plugin or extension boundary, where placing the sandbox precisely is the whole design.
- Capacity planning for many isolated instances, where instantiation cost and memory-per-instance are the numbers that matter and are very different from a process model.
When it hurts
- Choosing on a single benchmark number. The gap varies by an order of magnitude across workload types, and an average across them describes no real program.
- Assuming a future runtime will close the gap. Three of the four causes are guarantees rather than implementation deficiencies.
- Adopting WebAssembly without a sandboxing, portability or instance-density requirement, in which case its costs are being paid for nothing.
What it costs
Every one of these is paid by something.
- A sandbox by construction buys the ability to run untrusted code without an operating-system boundary, and pays with bounds-checked memory, a copying boundary and no direct system access — all of which cost measurable performance.
- One artifact for every architecture buys the disappearance of a build matrix and a release process per platform, and pays by making performance a property of the host runtime rather than of anything you tested.
- Microsecond instantiation buys instance densities a process model cannot approach, and pays with an ahead-of-time compilation step and a cache to manage, or with load-time compilation that gives the startup advantage straight back.
- A specified, verifiable target buys checkability, and pays with a feature surface that lags hardware — 128-bit SIMD when the machine has wider vectors, threads behind a proposal, and no access to target-specific instructions at all.
- Native code buys full optimization and unrestricted system access, and pays with a build matrix, no verifiability, and isolation that must be bought separately from the operating system at a much higher startup cost.
What else you could do
What a different compiler or language does instead, and when that is better.
- Containers give process isolation with a full system interface, at millisecond startup and megabyte images — the usual thing WebAssembly is compared against server-side.
- MicroVMs give hardware-level isolation with startup between the two, and a much larger memory footprint per instance.
- A language-level VM such as the JVM offers portability and sandboxing with a large runtime and a single-language commitment.
- An embedded interpreter for a scripting language is far simpler to integrate and restricts extensions to that language, which is the constraint WebAssembly removes — see
[[dsl]]. - Native code with software fault isolation applies comparable guarantees by instrumenting machine code, avoiding a new format at the cost of instrumentation overhead and a much harder correctness argument.
See it for yourself
The flag, dump or tool that shows you this directly.
- Measure instantiation and execution separately:
wasmtime compileproduces a precompiled artifact, so the difference between running it and running the.wasmis exactly the compilation cost. perf staton the runtime process, and a runtime's own profiling hooks, separate time inside compiled module code from time in host calls.wasm-objdump -xshows which proposals a module's opcodes require; the runtime's feature flags show which it will accept.- Compare the same source compiled natively and to Wasm on the same machine, with the same input, and vary one thing at a time — that is the only comparison that answers anything about your program.
- Count boundary crossings explicitly by instrumenting the imported functions; this is usually the first surprise in a real workload.
twiggyfor module size, andsizeorbloatyfor the native binary, remembering that the two numbers include different things.
Plausible wrong readings
Stated the way a confident engineer states them.
- "WebAssembly runs at near-native speed." It runs within a factor that depends heavily on the workload and the runtime, and for boundary-heavy code the factor is not small.
- "The gap will close as runtimes improve." Part of it will. Bounds checking, the copying boundary and the absence of direct system calls are guarantees, not deficiencies.
- "Wasm is smaller than a native binary." Sometimes. The comparison depends entirely on whether the language ships a runtime inside the module and whether the native binary links the system libraries dynamically.
- "Startup is fast, so cold starts are solved." Startup is fast when the module is precompiled. A host that compiles at load pays milliseconds, which is the same order as the thing it was replacing.
- "It is sandboxed, so it is safe to run anything." It is safe from reaching outside its granted capabilities. It can still exhaust time and memory unless the host limits both.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
WebAssembly gives you one file that runs everywhere, starts almost instantly, and cannot touch anything you did not hand it. A native binary gives you full speed and full access to the machine, one build per architecture, and no way to check it before running it. Most of the time the choice is decided by whether you trust the code, not by how fast it is.
practical
Measure three things separately before deciding: compilation, instantiation and execution — a precompiled module changes the startup story by orders of magnitude, and quoting a load-time-compiling number as WebAssembly's startup is the most common mistake. Then count boundary crossings, because a chatty interface is usually the real cost and no runtime improvement helps. And pin the proposal set of every runtime you deploy to, since a mismatch is a validation failure at load rather than a slow path.
advanced
The comparison is really about where the safety argument lives. Native code carries no argument at all: the bytes are unverifiable, so isolation must come from the hardware and the operating system, which is why it costs a process, a page-table switch and milliseconds. WebAssembly moves the argument into the format, so isolation costs a validation pass and a bounds check, and the unit of isolation drops from a process to an allocation. Everything else follows: microsecond instantiation is possible because an instance is not an operating-system object; the copying boundary exists because there is no shared address space to hand a pointer across; the performance gap exists because the argument has to remain true on every memory access. Read as a whole, WebAssembly is a bet that a verifiable format is cheaper than a hardware boundary — and the cases where it wins are exactly the cases where you want many isolated things, quickly, from code you do not trust.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- Where does the WebAssembly performance gap come from, and which parts of it will close with better runtimes?
- When would you choose WebAssembly over a native binary, and when is that the wrong call?
- Why can a Wasm instance start in microseconds when a process takes milliseconds?
Connections
- DevOps / Production Engineering — Choosing an isolation unit for a platform: processes, containers, microVMs or Wasm instancesThis lesson compares two compilation targets. Which isolation unit a platform standardises on also involves scheduling, observability, image distribution and operational familiarity, and that decision is owned there.
- Observability & Performance Engineering — Benchmarking two runtimes fairly across workload classesEvery claim in this lesson is a measurement claim, and the discipline of measuring compute-bound, memory-bound and boundary-bound workloads separately rather than averaging them is owned there.