Skip to content

wasm-experiments

A collection of hands-on experiments exploring WebAssembly (WASM) both in the browser and as an alternative to traditional container runtimes. Each experiment tests a concrete hypothesis, measures real numbers, and documents what held up and what didn't.

Two use cases, constantly conflated

Almost every argument about WebAssembly gets muddled because WASM has two entirely separate deployment stories, and people cite evidence from one to make claims about the other. Get this straight first; everything else in this repo depends on it.

1. WASM in the browser (client-side)

The module runs inside the user's browser, hosted by its JavaScript engine (V8, JSC, SpiderMonkey). It is downloaded with the page and instantiated by JS.

  • The host already exists. There is no server, no runtime to provision, nothing to deploy. The browser is the sandbox.
  • Containers are irrelevant here. There is no container to replace. "WASM vs Docker" is a category error in this use case.
  • Why you'd use it: near-native compute in the page, a language other than JavaScript, and bit-identical results across engines (017).
  • Experiments: 004, 006, 010, 008

2. WASM server-side

The module runs on your infrastructure, hosted by a runtime you choose. This is where the Docker comparison actually lives — and it splits three ways, which matters enormously because the numbers differ by two orders of magnitude:

What hosts the module Cold start Experiments
a. Embedded / CLI Your own process embeds wasmtime as a library; WASM is a sandboxed plugin ~40 ms 009, 011, 015, 016
b. WASM runtime inside a container An OCI container runs Spin/wasmtime, which runs your module 1,238 ms 003 legs 1b/2c
c. WASM as the workload A serverless host or a containerd/crun shim runs the module directly — no Linux userspace +48 ms over the container floor (018) 014, 018

(b) is the trap. Measured in 003: the same module cold-starts in 177 ms under spin up natively and 1,238 ms wrapped in podman. The container tax of +1,061 ms is six times larger than the WASM runtime's entire startup, and 23x the ~54 ms that 001 leg 1 measured for the plain Flask-in-a-container baseline it was supposed to beat. Putting WASM in a container discards most of what WASM bought you. It only makes sense when you need OCI/Kubernetes orchestration badly enough to pay for it.

(c) is what "Docker is dying" actually refers to — the container is eliminated, not merely filled with something lighter.

One more trap worth naming: running a browser server-side to execute WASM (Playwright + headless Chromium) inherits Chromium's own floor of ~600 ms and ~240 MB regardless of what runs inside it — measured in 002, which is slower to cold start than a container. Use case 1's host does not transplant into use case 2.

Why

"If WASM+WASI existed in 2008, we wouldn't have needed to created [sic] Docker. That's how important it is. Webassembly on the server is the future of computing. A standardized system interface was the missing link. Let's hope WASI is up to the task!"

— Solomon Hykes, co-founder of Docker, 27 March 2019

That quote launched a thousand "Docker is dead" posts. It is also almost always cited without Hykes' own clarification, posted the same day:

"'So will wasm replace Docker?' No, but imagine a future where Docker runs linux containers, windows containers and wasm containers side by side. Over time wasm might become the most popular container type. Docker will love them all equally, and run it all :)"

Solomon Hykes, same thread

Coexistence, not replacement — and that second prediction has aged better than the first. 003 already runs a WASM runtime inside a container, and containerd WASM shims make "wasm container type" a literal, shipping reality.

The premise these experiments test: WASM runtimes can replace Docker containers for serverless-style workloads — smaller artifacts, faster cold starts, lower memory. Each experiment validates (or refutes) part of that claim with reproducible benchmarks on real hardware.

Reference: AWS's Stealth Container Killer

Experiments

# Name Status What it tests
001 hello_world done Flask/Docker vs Pyodide/Chromium vs Wasmtime — cold start, memory, throughput
002 chromium_sandbox done Chromium+Pyodide isolation — worker pools, BrowserContext pooling
003 wasm_compile done Compiling JS/Python/Rust to .wasm via Spin/componentize-py/cargo
004 static_wasi_hello done Static HTML page, zero server at execution time
005 stdout_capture_load done stdout/stderr capture correctness at scale
006 worker_kill_switch done Worker.terminate() latency against infinite loops
007 custom_runtime_vs_interpreter done Custom JS runtime vs componentize-py vs Pyodide
008 js_vs_wasm_crossover done Where WASM stops paying: rematches 010's 1.68x to parity (JS formulation matters more than language — deopts, not branches); marshal-vs-compute across scalars/arrays/strings/objects; batching crossover at K≈4-16
009 rust_native_host done Native Rust host embedding wasmtime directly
010 mastermind_web done Browser WASM: Rust + AssemblyScript engines
011 mastermind_cli_wasi done CLI WASM with real WASI stdin/stdout
012 stdlib_size_matrix done Stdlib feature impact on binary size
013 unicode_strategies done Unicode handling: embed vs delegate to host
014 wasm_webserver done TCP vs serverless: guest-owned vs host-owned sockets
015 postgres_bridge done Database access via host imports (no HTTP sidecar)
016 ffi_assemblyscript done FFI overhead: AssemblyScript calling Rust host
017 float_determinism done Cross-engine float determinism: JS Math.* vs WASM libm, ULP-level
018 wasm_platforms done Local-testability matrix across 4 container architectures (incl. verified podman+crun WASM-as-workload) and 4 platform runtimes; wasi-http portability demonstrated across 5 runtimes
019 wasm_debugging done Debuggability vs binary size — DWARF/name-section cost across 4 tiers; the other side of 010
020 collections_abi done Strings, lists, maps, sets across the boundary — WASM has no aggregate types, so six conventions compared (WAT, ptr+len, wasm-bindgen, AssemblyScript, Component Model, externref)

How to read these

Not every experiment is a rigorous benchmark, and that's deliberate. Each kind is held to a different standard, so read the numbers accordingly:

Kind Examples Standard it's held to
Benchmark 001, 002, 003, 008, 014, 015, 016 Real numbers, same-session fairness, hypotheses marked from measured data
Mechanism explainer 010, 013, 019, 020 Show how it works; numbers are illustrative, the tradeoff is the deliverable
Correctness probe 005, 011, 017 Does it behave as specified? Bit-exact where it matters, not perf-focused
Survey 018 Verified-installable claims only; sources cited; what-I-ran kept separate from what-I-read

A number from a mechanism explainer is there to make a tradeoff legible, not to be cited as a performance result. A benchmark's numbers are meant to be argued with.


Key Learnings

1. Deployment Architectures

Client-side (browser)

  • WASM runs in browser via WebAssembly.instantiate() — no server round-trips after initial load
  • Ideal for compute-heavy UI (games, codecs, crypto) where latency matters
  • Example: 010 — Mastermind scoring in 950-byte WASM
  • Loading strategy: fetch() the binary, or base64-inline it into a JS module to dodge CORS/MIME/file:// issues — see 010's fetch-vs-inline measurements

Server-side (wasmtime)

  • Embed wasmtime as a library — single process, no container overhead
  • Cold start: ~40ms (vs 500ms+ for containers)
  • Example: 009 — native Rust host

Serverless (Spin/Cloudflare Workers)

  • Host owns the socket, guest only handles requests
  • Fastest cold start: host pre-warms the listener
  • Example: 014 — Spin vs raw TCP

Measured cold starts. Every row cites the leg it came from; where a figure was never measured, it says so rather than carrying an estimate.

Architecture Cold start Memory RSS Source
Pyodide in headless Chrome 5,837 ms 1,109 MB 001 leg 2b
Spin runtime inside a container 1,238 ms ~424 MB 003 leg 1b
Headless Chromium + native WASM ~620 ms ~375 MB 002 leg 5a
wasmtime embedded, first run of a fresh binary 190 ms not measured 009
spin up native 177 ms 16 MB 003 leg 1a
Flask in a container (image pre-built) ~54 ms not measured 001 leg 1
wasmtime standalone ~43 ms 20 MB 001 leg 3
wasmtime embedded, warm 4 ms not measured 009
Serverless, host owns the socket not measured not measured 014 has no cold-start benchmark

Resolution caveat: cold_start_ms polls at 100 ms intervals, so ~43 ms and ~54 ms both mean "answered on the first poll". Treat them as one class and do not read an ordering between them.

Three things in this table are uncomfortable for the premise this repo set out to test, and are stated rather than smoothed:

A plain Flask container cold-starts in ~54 ms — faster than spin up at 177 ms. Experiment 001 hypothesised containers at "~500 ms+" (H2) and measured ~54 ms. For a pre-built image on a warm machine, container startup is not the bottleneck the WASM pitch assumes. The container tax is real when you wrap a WASM runtime in one (1,238 ms), not when you simply run a process in one.

Containerising a WASM runtime is 23x worse than the container it replaces — 1,238 ms against ~54 ms. That is the strongest single number here, and it is an argument against architecture (b), not against containers.

The serverless row is empty. 014 compares guest-owned versus host-owned sockets by artifact size and design, and never benchmarks cold start. The "~5 ms serverless" figure that previously sat here was inherited from an early draft, was never measured anywhere in this repo, and has been removed.

2. WASM Targets

Target Type Use case Size optimization
wasm32-unknown-unknown Core module Browser, no WASI wasm-opt -Oz (60-70% reduction)
wasm32-wasip1 Core module CLI, stdin/stdout wasm-opt -Oz --enable-bulk-memory
wasm32-wasip2 Component Sockets, HTTP, serverless wasm-tools strip (~10% reduction)

Key insight: Components are the future (composable, typed interfaces), but Binaryen doesn't support them yet — use wasm-tools strip instead of wasm-opt.

3. Float math: the instruction set is not your stdlib

The single most transferable gotcha found in this repo, because it silently costs both size and speed and produces no warning. WASM's float instruction set tracks IEEE-754's own required/recommended split:

  • IEEE-754 §5 requires correctly-rounded add, sub, mul, div, sqrt, remainder, conversions — WASM has an instruction for each.
  • IEEE-754 §9.2 lists sin, cos, exp, log, pow as recommended, i.e. optional — WASM has none of them.

"Does WASM have an instruction for this?" is nearly the same question as "does IEEE-754 require it?" Whatever it lacks, your guest must carry as code.

Measured, on wasm32-unknown-unknown (008, 017):

Situation Example Consequence
Instruction exists, your stdlib exposes it abs, min, max, copysign free — one opcode
Instruction exists, your stdlib hides it Rust no_std: sqrt, floor, ceil, trunc 9–15x slower, software routine for a single opcode
Instruction exists, semantics differ round (half-away-from-zero) vs f64.nearest (half-to-even) compiler emits a helper, not the instruction
No instruction exists sin, cos, exp, log, pow must bundle libm: +10.2 KB, buys bit-identical results across engines

Only the last row is a real trade. The second is pure waste — Rust's core/std boundary is a packaging decision that predates its WASM backend and doesn't line up with the instruction set, so a no_std build strands four instructions that are right there.

If you generate or hand-write WASM, map your math surface onto the instruction set rather than onto another language's stdlib: lower sqrt/floor/ceil/trunc/abs/ min/max/copysign directly, verify semantics before lowering (round is the trap), and bundle libm only for the genuinely absent set.

Detect it in any build you already have — look for the instruction you expect:

wasm-tools print module.wasm | grep -c 'f64.sqrt'
# 0 while calling sqrt => a software routine got linked in

4. Host Bridging Patterns

Three ways to extend WASM capabilities:

Pattern Latency Complexity Use case
Host imports ~5ns/call Moderate Crypto, compression, DB
HTTP sidecar ~2ms/call Simple Polyglot, legacy
Embedded in host 0 High Tight integration

Experiment 015 showed host imports are essentially free (5ns overhead). For database access, eliminating the HTTP sidecar reduced latency from 2ms to 460μs — 4x improvement.

Experiment 016 demonstrated FFI overhead is negligible:

  • Pure FFI call: 5ns
  • SHA256 (1KB) via host: 764ns (vs ~50μs in pure WASM)
  • Hardware crypto (SHA-NI, AES-NI) makes host-side 50-100x faster

5. Binary Size Optimization

For trivial functions, optimization is dramatic (16KB → 950B). For real applications, gains are modest but worthwhile.

Rust-side (all targets):

[profile.release]
opt-level = "z"    # size
lto = true         # link-time optimization
panic = "abort"    # no unwinding
strip = true       # symbols

Post-process:

# Core modules
wasm-opt -Oz input.wasm -o output.wasm

# Components (wasip2)
wasm-tools strip input.wasm -o output.wasm

HTTP compression: WASM compresses extremely well (60-70% with brotli). Pre-compress and serve with Content-Encoding: br.

Experiment 017 measured the flip side of size optimization: bundling a software libm for deterministic trig (sin/cos/tan/pow/exp/log) instead of relying on a host instruction costs 10.2KB on top of a 180B arith-only baseline — determinism is bought with bytes, not free.

Experiment Raw Brotli Savings
010 Rust engine 950B 634B 33%
010 AS engine 481B 268B 44%
014 Leg A (TCP+SQLite) 1.1MB 448KB 61%
014 Leg B (Spin) 222KB 81KB 64%

6. Language Comparison

Language Strength Weakness Best for
Rust Full control, no_std, fast Verbose, compile time Performance-critical
AssemblyScript Tiny binaries, WASM-native Limited stdlib Simple compute
Python (Pyodide) Ecosystem, rapid dev 5s+ cold start, 300MB+ Prototyping only

AssemblyScript produces smaller binaries for trivial functions (481B vs 950B) because it compiles directly through Binaryen. For complex applications, the difference narrows.

Experiment 002 (legs 5a/5b) shows Pyodide's cold-start/memory cost is Pyodide-specific, not inherent to WASM-in-a-browser: native Rust/AssemblyScript WASM cut cold start ~2.9x (1.8s→0.6s) and memory ~1.6x (602MB→375MB) versus Pyodide for identical CPU-bound work in the same harness.

7. WASI Capabilities

Feature wasip1 wasip2
stdin/stdout
Filesystem
Environment vars
Sockets
HTTP
Clocks

Experiment 011 proved genuine interactive I/O works under wasip1. The gap in MVL's WASM backend is implementation-specific, not a WASI limitation.

Experiment 018 demonstrated the wasi-http convergence directly: one component built once ran unmodified on Spin, raw wasmtime serve, and wasmCloud. Cloudflare Workers and Fastly's Viceroy could not run it as-is — Workers because workerd/V8 only parses core Wasm modules (not the Component Model's binary format at all), Viceroy because its Component Model support is explicitly experimental. Portability is real but not universal yet.

8. Database Access

Approach Latency Size impact When to use
Embedded SQLite ~1ms +1MB Single-tenant, ACID needed
Host bridge (Postgres) ~460μs ~400B guest External DB, connection pooling
Spin KV store ~1ms 0 Serverless, simple K/V

Experiment 014 showed embedded SQLite works in WASM (requires WASI SDK for C compilation). Experiment 015 showed host imports beat HTTP sidecars by 4x for database access.


Structure

experiments/
└── NNN_name/        # Self-contained experiment
    ├── README.md    # Hypotheses, methodology, results
    ├── Makefile     # build, test, benchmark, size targets
    └── leg*/        # One directory per variant under test
install.sh           # Check and install prerequisites

Prerequisites

Run ./install.sh to verify your environment.

Tool Purpose Install
podman or docker Container runtime brew install podman
wasmtime WASM runtime brew install wasmtime
rustup + targets Compile Rust to WASM rustup target add wasm32-wasip2
wasm-opt Binary optimizer brew install binaryen
wasm-tools Component tools cargo install wasm-tools
node / npm JS tooling brew install node
hey HTTP benchmark brew install hey

License

Copyright 2026 Schuberg Philis B.V. — Apache License 2.0. See LICENSE.

Used by

Contributors

Languages