Summary from the AI Overlord
The Cost of WASM: Containers, Embedded Modules, and the Copy Tax
This page is an explicit AI summary. A human asked questions. A model answered. Read it as a working brief, not as original research and not as medical advice.
This is a cost model, not a sermon. WebAssembly is sold as "containers but smaller" and "native but portable." Both claims are directionally true and operationally sloppy. The bill shows up in three places: the artifact you ship, the resident set you keep warm, and every byte that crosses the host/guest wall. Rust and C compiled to WASM and embedded inside another process is a different product than a WASM OCI container. Mixing those two is how people get 50x wins on slides and 1.5x in production.
N=1 benches are a starting map. They are not truth until you run the same module on your runtime, your ABI, and your call pattern.
Three products that share a name
- A
.wasmmodule. Compact bytecode. No OS. Linear memory. This is the thing that is actually small. - A WASM container. That module wrapped as an OCI artifact and executed through containerd/crun/runwasi plus Wasmtime, WasmEdge, Wasmer, or WAMR. You reintroduced shims, WASI, and often a leftover Linux story.
- An embedded module. A host application (usually Rust or C++) links a runtime and instantiates guest code in-process. Isolation is the linear-memory sandbox plus whatever host functions you import. There is no container. The runtime is the tax.
If you only remember one sentence: the module is cheap; the boundary is not.
Artifact size
Raw modules vs typical Linux images is the one claim that survives contact with measurement.
| Class | WASM | Typical Linux OCI | Honest ratio |
|---|---|---|---|
| Tiny WASI CLI / hello | 4 KB–few hundred KB | 5–50 MB Alpine | tens to thousands |
| Rust HTTP handler | 1–5 MB | 50–200 MB | ~20–50x vs lazy images |
| WasmEdge QuickJS + app | ~1 MB | 350 MB+ Node | ~100x vs stock Node |
| Godot 4 engine export | ~35 MB | 144–421 MB language bases | ~4–12x |
| Multi-lang microbenches (TinyGo / Rust / C++) | 96–257 KB module; compressed OCI wasm ~40–100 KB | native 0.6–2.2 MB; OCI native larger | module ~4–10x; packaged wasm OCI often 15–27% of a comparable native OCI |
Vendor writeups in 2025–26 repeat "1–5 MB vs 50–200 MB" and "~50x." Academic container work is less drunk: CLOSER 2025 and a Luxembourg thesis saw wasm OCI images about 15–27% of native counterparts and pull-time cuts up to ~25%. ACM TOSEM ("WebAssembly for Container Runtime: Are We There Yet?") reported about 75x smaller images against the Docker images they built — which still carried OS support the wasm artifacts did not.
Fact check: 50x–75x is vs fat Dockerfiles. A statically linked musl binary in FROM scratch collapses the gap toward 2–5x, sometimes less. Compare like with like or you are lying with a spreadsheet.
Runwasi shims have been shown as small as 2.8 KiB versus a 9.9 MiB distroless Wasmtime container. That is the packaging layer, not the guest heap.
Resident set and density
Linear memory is the real bill. A 1 MB .wasm that grows a 256 MB heap is a 256 MB tenant.
Working ranges from production writeups and 2025–26 runtime papers:
- Small cached module, JIT/AOT runtime already loaded: roughly 1–20 MB RSS per instance for toy-to-service code.
- Docker app containers: 30–200 MB is the common idle band once you count shim + filesystem + libc.
- A crun + WAMR integration measured ~16% less memory than a Debian-slim Python container and 10–40% less than other WASM shims, depending on whether you trust
freeor the metrics server. That is a real win. It is not 100x density. - TOSEM: WASM containers showed no memory win vs Docker and ~51% slower startup. Bare Wasmtime VM vs the same module inside a container: about 143x startup for half their suite. Container plumbing ate the lightness.
On tiny MCUs the story is harsher. treVM (IEEE DCOSS-IoT 2026) and FOSDEM 2026 Rust-on-device talks: embedding a runtime on constrained chips more than doubled Flash and RAM for a simple capsule. WAMR AoT footprints in the ~68–80 KiB class; Wasmi and Wasmtime hosts in the hundreds of KiB. You pay the runtime even if the guest is 4 KB.
End game for density: many tiny, untrusted, short-lived tenants on one box. Not "replace Postgres."
Compute: Rust or C compiled to WASM
Same source, two targets. That is the only fair fight.
AOT/JIT runtimes (Wasmtime Cranelift, Wasmer, WasmEdge) on server-class CPUs:
- Tremhost Labs 2025, Rust, Wasmtime 19, same codebase native vs
wasm32-wasi: SHA-256 1.25x, image processing 1.66x, Fannkuch-Redux 1.76x. Band they advertised: 1.2–1.8x for compute-bound work. - Frank Denis libsodium-style suites: median often around ~2x vs native-with-arch-intrinsics once you throw out AES outliers. AES-round crypto without hardware opcodes has been measured around 80x slower. That is not "WASM is slow." That is "the ISA does not have AES-NI."
wasm2ccompiled with a real C compiler can match or beat dedicated WASM JITs on some suites. The runtime is not magic. The generated code is.- Pulley (Wasmtime interpreter): expect ~10x vs native or Cranelift. Use it for portability, not hot loops.
- Interpreters on the fast end (Wasmi 2.0): much better than old interpreters, still not AOT.
Older / ABI-heavy embedding: University of Calgary / LTB 2022, Rust guest in Wasmer used as a scripting engine for a cache simulator: 5–10x vs native. They showed the gap was dominated by calls into WASM, not by the inner loop once you were inside. Direct integer args beat serialization. Bytemuck-style C structs in static guest memory beat dynamic alloc per call (dynamic was about 2x slower than reused static buffers).
Hardware-specialized escape hatch: a SHA-1 intrinsic experiment on Wasmtime reported 1.3x native when the engine mapped engine-intrinsics onto AArch64 SHA instructions, and >9x on the software fallback. If your workload is "the CPU has a button and WASM does not," you will eat that button or you will lose.
Panel.
- Compiler engineer: 5–15% is the brochure. 1.2–2x is what shows up when you do not cheat the native baseline of SIMD and ISA extensions. 5–10x is what shows up when you treat WASM like a plugin VM and bounce across the ABI every few microseconds.
- Runtime vendor: cache compilation, AOT
.cwasm, keep the engine process-alive, reuse instances. Cold compile is a different product from warm invoke. - Skeptic: SPEC-in-browser work from years ago said up to ~2.5x. Server AOT closed some of that and opened a new hole: hostcalls and copies. Do not quote 2019 browser numbers for 2026 Wasmtime embeds.
The copy tax (this is the actual design constraint)
Guest memory is a bounded byte array. The host does not hand the guest a kernel pointer. Full stop.
Embedded host ↔ guest (Rust/C host, Rust/C guest)
Typical path with Wasmtime/Wasmer:
- Guest passes an
i32offset + length into linear memory. - Host computes
memory.data_ptr().add(offset)andmemcpys or reads in place for the duration of the call. - Complex types: you either (a) lay out a C struct in guest memory and pass offset/len, or (b) serialize (bincode, JSON, component-model lift/lower).
Measured-class numbers that keep recurring:
| Crossing | Cost |
|---|---|
| Integer / primitive call | ~50–100 ns in JS-bridge benches; native embeds are in the same order once you leave the function-call tax of the runtime |
| Short string (encode + alloc) | ~0.6–2.5 µs |
| Copy 1 MB | ~1–3 ms (memcpy bound) |
| Shared memory / SharedArrayBuffer style access | ~15 ns to touch; no copy |
| Guest malloc + second copy (naive preview2 list return into a caller buffer) | two copies + a guest allocator round trip |
Wasmer scripting work: caching the runtime objects and using static guest buffers cut the per-call tax enough that the remaining gap was "we are in a VM," not "we copy the world." Dynamic allocation of a fresh guest buffer every call was about twice as slow as reuse.
Component Model canonical ABI is shared-nothing by default. Value types (strings, lists, records) lift and lower. GitHub component-model #314 documents the ugly recv path: user buffer → host → host malloc in guest → copy in → return ptr → guest memcpy to user buf → free. wasi-libc has been called out for unconditionally copying preview2 lists into the caller buffer. That is an implementation lag, but it is what C/Rust-on-wasi-libc guests actually run today unless you bypass libc.
WASI 0.3 streams are the honest fix for bulk I/O: stream.read writes into a guest-provided linear-memory slice. A competent host can aim kernel/io_uring at that address. You still pay the copy the kernel was going to do into userspace. You stop paying a second copy into a temporary list. Luke Wagner's position is not "zero copy everywhere." It is "do not add copies on top of the one you already owed the device."
WASM containers specifically
TOSEM measured 1-byte file read/write/delete in Wasmtime/Wasmer containers at ~2–11x Docker. They blamed WASI, not containerd. Chatty syscalls lose. Large sequential I/O with fat buffers is memcpy plus a trampoline and is often within tens of percent of native if the runtime is not doing the double-copy list dance.
Embedded module: the bill of materials
If you compile Rust or C to WASM and load it inside an app, you pay four stacks at once.
- Engine in the host binary. Wasmtime linked into a host is not free. Minimal
libwasmtimefigures in the tens of MB have been quoted for full engines; embedded/no-SIMD/Pulley configurations exist to cut that. On MCUs you are fighting for tens to hundreds of KiB. On servers you are fighting for "did I just add a compiler to my process." - Compile or deserialize. First load of a module is Cranelift/LLVM time, or a cached AOT artifact. Wasmer 4.2 claimed ~40–50% faster module load with zero-copy deserialization of the compiled artifact (cowsay 2.65→1.57 ms, python 43→22 ms, php 141→74 ms in their own table). That is load, not execute.
- Instance + linear memory. You choose a memory maximum. That reservation, plus guard pages, is what ops will see. CoW memory-init helps cold instances share zeros. It does not help a guest that touches every page.
- Every export/import. A hot game loop that calls
step()10k times/sec with a 64 KB state blob is a different machine than a filter that runs once per HTTP request on 4 MB.
Design rules that actually move the needle:
- One coarse call that processes a buffer beats a thousand fine calls. Always.
- Pre-allocate guest scratch. Never malloc on the crossing.
- Prefer C ABI structs (fixed layout,
Copy) in linear memory over serialization. - Keep the instance alive. Instantiation is not a function call.
- AOT the module in CI. Do not JIT in the request path unless you measured that the compile is cheaper than your SLA.
- If the guest needs AES-NI, GPU, or AVX-512, you are in host-function or intrinsic territory. Plain WASM will lose on purpose.
What to budget (steal this table)
Assume a competent AOT runtime, Rust or C guest, no AES-class ISA gap, preview1 or a non-stupid preview2 path.
| Item | Budget |
|---|---|
| Module on disk | 0.1–5 MB for services; more if you shipped a runtime-in-the-module (QuickJS, a game engine) |
| OCI wasm image vs lazy Docker | 5–50x smaller |
| OCI wasm image vs scratch static native | ~1.5–4x smaller, sometimes a wash |
| Idle RSS per small instance | 1–20 MB + whatever heap you granted |
| Compute inside the guest | 1.2–2x native for straight-line code; 2–10x if the ABI is chatty or you are interpreting |
| Interpreter / Pulley | ~10x |
| 1 MB host↔guest copy | 1–3 ms |
| Primitive call | tens to hundreds of ns, plus runtime trampoline |
| Tiny WASI I/O in a container | 2–11x Docker in the published 1-byte tests |
| Bulk I/O, big buffers, one copy into linear memory | often acceptable; design for this |
| Cold containerd+shim start | can erase the "WASM starts in 1 ms" story; measure the stack you will ship |
Critical review
Vendor camp is not wrong about size and isolation. A .wasm is a better unit of untrusted code than a Linux userland. Capability-based WASI beats "here is a kernel, please do not escape." Cold instantiate of a cached module in a long-lived engine is milliseconds or less. That product exists.
TOSEM / container-integration camp is not wrong that the moment you dress WASM as an OCI container so the platform team will touch it, you buy back startup and often memory. "WASM containers are always lighter" failed their measurements. Believe the paper until your node proves otherwise.
ABI camp is the one you should actually listen to if you are embedding Rust or C. Shared-nothing value types copy. Streams and host-written linear memory exist so large blobs are not value types. If your plugin API looks like a REST chat inside one process, you built the slow path on purpose.
Method note. Almost every public "WASM vs native" number smuggles a baseline choice: native with -march=native and SIMD vs WASM with SIMD128; cached AOT vs cold JIT; in-process embed vs containerd; 1-byte I/O vs 1 MB I/O. Change one of those and the ratio moves by more than the thing you thought you were measuring. If a number does not name the runtime version, the memory maximum, and whether the crossing copied, throw it out.
Where you are, next step, end game
Current default in the industry: treat WASM as a portable sandbox for short, coarse, untrusted work. That is the winning shape.
Next step if you are embedding Rust/C: write the ABI first. Freeze a buffer protocol. AOT in CI. Benchmark the crossing with your real payload sizes before you benchmark the inner loop. The inner loop is rarely the fire.
End game: a host that maps device buffers into guest linear memory (or 0.3 streams that do the same), AOT artifacts as the unit of deploy, and containers only where the orchestration plane demands OCI. WASM does not replace the kernel. It replaces the mistake of giving every plugin a distribution and a syscall table.
If the work is long-running, syscall-heavy, ISA-specialized, or already a static binary you trust, stay native. Paying 1.5x compute and a memcpy tax to feel modern is not engineering. It is costume.