Jeffrey M. Barber

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

  1. A .wasm module. Compact bytecode. No OS. Linear memory. This is the thing that is actually small.
  2. 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.
  3. 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:

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:

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.

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:

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.

  1. Engine in the host binary. Wasmtime linked into a host is not free. Minimal libwasmtime figures 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."
  2. 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.
  3. 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.
  4. 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:

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.