Wasm in Production — Professional Level¶
Table of Contents¶
- Introduction
- The Two Runtime Contracts:
js/wasmandwasip1 - wazero's Execution Model: Compiler vs Interpreter
- Compile-Once Lifecycle and Its Memory Accounting
- The Host ABI:
go:wasmexport,go:wasmimport, and Memory - Enforcing Limits: Memory Pages, Context Cancellation, Instruction Budget
- WASI Capability Surface: Preopens, Clock, Randomness, Env
- Streaming Compilation Internals (Browser)
- Compression and Caching as a Toolchain Concern
- A Hermetic Build Pipeline for Both Targets
- Operational Playbook
- Edge Cases the Runtime Reveals
- Professional-Level Checklist
- Summary
Introduction¶
The professional level treats "Wasm in production" as the surface of several contracts: the toolchain's ABI between Go and the runtime, the runtime's contract for memory and cancellation, the browser's contract for streaming compilation, and the build pipeline that must produce a reproducible, version-matched pair of artefacts for each target. Misunderstanding any one of these produces the opaque failures that dominate Wasm incident channels: a blank page, an intermittent corrupt decode, a host that OOMs under a hostile guest, a per-request latency cliff.
This file is for engineers who own Wasm infrastructure: the build pipeline, the wazero host, the CDN/serving layer, or a multi-tenant plugin platform. After reading you will know what each contract guarantees, where it does not, and how to operate it.
The Two Runtime Contracts: js/wasm and wasip1¶
Standard Go emits two distinct Wasm flavours with different host contracts. Conflating them is the root of many "it ran in dev" failures.
GOOS=js GOARCH=wasm | GOOS=wasip1 GOARCH=wasm | |
|---|---|---|
| Host | A JavaScript engine (browser, Node) | A WASI runtime (wazero, Wasmtime, edge) |
| Glue | wasm_exec.js (version-matched shim) | none — WASI is the ABI |
| Entry | go.run(instance) after instantiate | _start (WASI command) |
| IO | through JS (fetch, DOM, console) | through wasi_snapshot_preview1 syscalls |
| Sockets | via JS only | none in wasip1 |
| Use | browser delivery | server / edge embedding |
The js/wasm contract is a bidirectional bridge: wasm_exec.js implements the runtime's imports (timers, the event loop, syscall/js calls), and the Go runtime drives the JS event loop cooperatively on one thread. The wasip1 contract is a syscall surface: the module imports a fixed set of WASI functions the runtime provides. They are not interchangeable — a js/wasm binary cannot run in wazero, and a wasip1 binary cannot run in a browser without a WASI polyfill. (Details: 01-goos-js-wasm-browser, 02-wasi-and-wasip1.)
Since Go 1.24, //go:wasmexport lets a wasip1 library module export functions a host calls directly (the "reactor" pattern), in addition to the classic _start command model. This is the basis of the in-process plugin architecture.
wazero's Execution Model: Compiler vs Interpreter¶
wazero is a pure-Go runtime — zero CGo, zero external dependencies — which is exactly why it embeds cleanly into a Go host and ships as a single static binary. It offers two engines:
- The optimizing compiler (default on supported arch: amd64, arm64): translates Wasm to native machine code at
CompileModule. Higher first-compile cost, fast steady-state execution. Use it for hot, long-lived, repeatedly-invoked guests. - The interpreter (
NewRuntimeConfigInterpreter): no native codegen, portable everywhere, lower startup cost, slower execution. Use it for short-lived, rarely-called guests, or unsupported architectures.
cfg := wazero.NewRuntimeConfigCompiler() // or NewRuntimeConfigInterpreter()
rt := wazero.NewRuntimeWithConfig(ctx, cfg)
The choice is a throughput-vs-startup tradeoff; measure with your guest and call pattern. The compiler's win materializes only when execution dominates compile-amortized-over-N-calls.
Compile-Once Lifecycle and Its Memory Accounting¶
The runtime exposes three lifecycle objects with very different costs:
Runtime— owns the engine and host modules. One per process (or per isolation domain). Closing it frees everything.CompiledModule— the result ofCompileModule(bytes). Expensive to produce, immutable, safe to share and reuse across instances and goroutines. This is the artefact you cache and version by digest.Module(instance) — the result ofInstantiateModule(compiled). Cheap, holds the guest's mutable linear memory, not safe to share. One per invocation for isolation.
rt := wazero.NewRuntime(ctx) // process-lifetime
defer rt.Close(ctx)
compiled, _ := rt.CompileModule(ctx, guestBytes) // once
defer compiled.Close(ctx)
// per call:
mod, _ := rt.InstantiateModule(ctx, compiled, cfg) // cheap
defer mod.Close(ctx) // frees this call's memory
Memory accounting that matters in production: each live instance holds its linear memory (up to its page cap) plus runtime bookkeeping. Worst-case host memory ≈ concurrent_instances × max_pages_per_instance × 64 KiB + compiled-module footprint. Size your instance-concurrency limit from that product and the host's headroom — this is a capacity calculation, not a guess.
The Host ABI: go:wasmexport, go:wasmimport, and Memory¶
The host and guest communicate over a numbers-and-memory ABI. There is no shared object graph; everything structured crosses as bytes in the guest's linear memory.
Guest exports (Go 1.24+):
//go:wasmexport process
func process(ptr, length uint32) uint64 { // returns packed (ptr<<32 | len)
input := readGuestMem(ptr, length)
out := transform(input)
return writeGuestMem(out) // allocate in guest, return location
}
Host reads/writes guest memory through api.Module.Memory():
fn := mod.ExportedFunction("process")
inPtr := writeIntoGuest(ctx, mod, input) // copy input into guest memory
res, _ := fn.Call(ctx, uint64(inPtr), uint64(len(input)))
outPtr, outLen := unpack(res[0])
out, _ := mod.Memory().Read(uint32(outPtr), uint32(outLen))
Host functions the guest imports (the capability door):
rt.NewHostModuleBuilder("env").
NewFunctionBuilder().
WithFunc(func(ctx context.Context, m api.Module, ptr, n uint32) {
msg, _ := m.Memory().Read(ptr, n)
logger.Info("guest", "msg", string(msg))
}).
Export("log").
Instantiate(ctx)
ABI design rules: keep the boundary signature small (pass (ptr, len) pairs, return packed locations), agree on an allocator convention (who allocates the result buffer — usually the guest exports a malloc/alloc the host calls), and never let a guest-supplied pointer/length go un-bounds-checked — Memory().Read returns ok=false on out-of-range, which you must honor. The cost of this boundary, and zero-copy techniques, are the subject of 04-wasm-interop-and-performance.
Enforcing Limits: Memory Pages, Context Cancellation, Instruction Budget¶
Three independent enforcement mechanisms; an untrusted guest needs all three.
Memory pages — hard ceiling on linear memory growth:
A guest memory.grow beyond the cap fails inside the guest (it observes an allocation error); the host is unaffected.
Context cancellation — wall-clock timeout that can interrupt compute:
rt := wazero.NewRuntimeWithConfig(ctx,
wazero.NewRuntimeConfig().WithCloseOnContextDone(true))
callCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
_, err := fn.Call(callCtx, args...) // ErrDeadlineExceeded-style failure on timeout
WithCloseOnContextDone(true) instruments compiled/interpreted code to poll for cancellation, so a pure CPU loop is interruptible. Without it, the deadline only takes effect at host-function boundaries — useless against for {}.
Instruction budget ("fuel") — deterministic, load-independent fairness, finer than wall-clock. wazero does not expose a single WithFuel(n) knob; the practical approaches are (a) periodic cancellation via WithCloseOnContextDone plus a watchdog, or (b) experimental/community metering listeners that count executed operations and cancel the context when a budget is exhausted. Deterministic metering matters for multi-tenant fairness (wall-clock varies with host load) and for reproducibility. Implement it as a budget that, when exhausted, cancels the call's context — reusing the same interruption path as the timeout.
WASI Capability Surface: Preopens, Clock, Randomness, Env¶
For wasip1 guests, wazero's WithFS/ModuleConfig controls the WASI capabilities. Deny-by-default means an empty config grants nothing:
cfg := wazero.NewModuleConfig().
WithName(""). // anon, allows many instances
WithFS(scopedReadOnlyFS). // preopen: only this subtree, RO
WithSysWalltime().WithSysNanotime(). // grant a clock, or omit to deny
WithRandSource(controlledRand). // deterministic/seeded if needed
WithEnv("TENANT", tenantID) // exactly the env you choose
Each grant is a deliberate capability: a preopened directory is the only filesystem the guest can see (it cannot escape it — there is no ambient root); omitting the clock denials make the guest non-deterministic-dependent; WithRandSource lets you make execution reproducible for testing or fair. The professional discipline mirrors POSIX least-privilege: grant the narrowest preopen, the fewest syscalls, nothing "to make it work." (Full WASI capability semantics: 02-wasi-and-wasip1.)
Crucially, wasip1 has no socket syscalls — there is no capability you can grant to give a guest raw networking. Network access for a guest is always via a host function you write, which is exactly where you enforce policy.
Streaming Compilation Internals (Browser)¶
WebAssembly.instantiateStreaming(fetch(url), importObject) overlaps three phases that otherwise run serially: network download, module compilation, and instantiation. The engine begins compiling functions as soon as enough bytes have arrived, so compile time hides behind download time. The hard requirement is Content-Type: application/wasm — the engine refuses to stream-compile any other MIME type and rejects the promise.
Implications you must engineer for:
- A misconfigured MIME type does not merely slow things —
instantiateStreamingrejects. You either fix the header or fall back to bufferedinstantiate(arrayBuffer), which ignores MIME but loses the overlap. Content-Encoding: gzip/bris transparent to streaming: the browser decompresses as it streams, and the engine still seesapplication/wasm. Pre-compressed delivery and streaming compilation compose cleanly.- For very large modules, the engine may tier-compile (a fast baseline first, optimized later). This is engine-internal; you cannot control it from Go, but it is why startup feels fast even on multi-MB modules.
Compression and Caching as a Toolchain Concern¶
Treat compression and cache-addressing as build-pipeline outputs, not server runtime behaviour:
- Pre-compress at build (
brotli -q 11,gzip -9) so serving is a static file send, not a per-request CPU cost. Brotli beats gzip ~10–15% on Wasm; ship both, negotiate viaAccept-EncodingwithVary: Accept-Encoding. - Strip the binary (
-ldflags="-s -w",-trimpath) to shave size and remove local-path leakage before compression. - Content-address the output (
app.<sha>.wasm) so it can be servedCache-Control: public, max-age=31536000, immutablewithout ever serving stale code; the unhashed entry HTML staysno-cache. - Hash the shim too (
wasm_exec.<sha>.js) so a Go upgrade never strands a stale glue file against a fresh binary.
The serving layer then has one job: send the right pre-built variant with the right three headers (Content-Type, Content-Encoding, Cache-Control/Vary).
A Hermetic Build Pipeline for Both Targets¶
A reproducible pipeline pins the toolchain and produces version-matched artefacts:
#!/usr/bin/env bash
set -euo pipefail
GO=go # pinned via go.mod 'toolchain go1.24.x' + 'go version'
# --- Browser target ---
SHIM="$($GO env GOROOT)/lib/wasm/wasm_exec.js"
[ -f "$SHIM" ] || SHIM="$($GO env GOROOT)/misc/wasm/wasm_exec.js"
GOOS=js GOARCH=wasm $GO build -trimpath -ldflags="-s -w" -o out/app.wasm ./cmd/web
H=$(shasum -a 256 out/app.wasm | cut -c1-8)
install -m644 out/app.wasm "public/app.${H}.wasm"
install -m644 "$SHIM" "public/wasm_exec.${H}.js"
brotli -q11 -k "public/app.${H}.wasm"; gzip -9 -k "public/app.${H}.wasm"
# --- Server/edge guest target ---
GOOS=wasip1 GOARCH=wasm $GO build -trimpath -ldflags="-s -w" -o out/guest.wasm ./cmd/plugin
GH=$(shasum -a 256 out/guest.wasm | cut -c1-12)
install -m644 out/guest.wasm "artifacts/guest.${GH}.wasm" # digest = version
echo "browser app.${H}.wasm + wasm_exec.${H}.js ; guest digest ${GH} ; $($GO version)"
Hermetic properties: pinned toolchain (so wasm_exec.js and ABI are stable), -trimpath (no machine-specific paths), content-addressed outputs, and the guest's digest is its version for the registry. Run it in one CI step; verify nothing else mutates the artefacts.
Operational Playbook¶
| Incident | First check | Likely fix |
|---|---|---|
| Blank page, no console error | wasm_exec.js version vs binary | re-pin shim to the building Go version |
Incorrect response MIME type | response Content-Type at CDN edge | set application/wasm at origin + CDN metadata |
| Intermittent corrupt decode | Vary honored by shared cache? | add Vary: Accept-Encoding, fix CDN cache key |
| Users on stale code post-deploy | filename hashed? Cache-Control on HTML? | content-hash filenames; no-cache on entry HTML |
| Server p99 cliff per request | recompiling per request? | CompileModule once; instantiate per call |
| Host OOM under load | per-instance page cap; concurrency limit | WithMemoryLimitPages; bound concurrent instances |
| Request hangs | WithCloseOnContextDone set? deadline set? | enable it; wrap calls in context deadline |
| Guest trap crashes host | host treating guest error as fatal | recover/log guest errors; never panic on a trap |
| One tenant starves others | per-tenant concurrency / fairness | semaphore per tenant; instruction-budget metering |
Edge Cases the Runtime Reveals¶
- A guest-supplied (ptr,len) can be out of range.
Memory().Readreturnsok=false; ignoring it reads garbage or panics. Always check. memory.growinvalidates prior memory views. A[]byteyou read before the guest grew memory may be stale; re-read after any call that can grow memory.- Named modules cannot be instantiated twice.
WithName("foo")collides on the second instance; useWithName("")for per-request instances. - Closing the
Runtimecloses everything. Adefer rt.Closein a request handler tears down the shared compiled module — close instances per request, the runtime at process shutdown. wasip1_startrunsmainand exits. For repeated calls you want the reactor pattern (//go:wasmexport), not re-running_start.- Brotli quality vs build time.
-q 11is slow; for CI iteration use-q 5locally and-q 11only on release builds. Content-Lengthmay be the compressed length. Progress bars computing against it while the browser decompresses can mismatch; measure against the encoded length you actually read.
Professional-Level Checklist¶
You have mastered this level when you can:
- Distinguish the
js/wasmbridge contract from thewasip1syscall contract and why binaries aren't interchangeable - Choose wazero's compiler vs interpreter from the call pattern and justify it
- Manage the Runtime / CompiledModule / Module lifecycle with correct sharing and memory accounting
- Design a numbers-and-memory ABI with
go:wasmexport/go:wasmimport, bounds-checked - Enforce memory pages, interruptible deadlines, and an instruction budget on untrusted guests
- Configure the WASI capability surface (preopens, clock, rand, env) deny-by-default
- Explain streaming compilation's MIME requirement and how it composes with
Content-Encoding - Build a hermetic pipeline producing version-matched, content-addressed artefacts for both targets
- Run the operational playbook from symptom to fix
- Anticipate the runtime edge cases (memory invalidation, named-module collisions,
_startsemantics)
Summary¶
Professionally, "Wasm in production" is the operation of several contracts. The toolchain emits two non-interchangeable flavours — js/wasm (a bidirectional JS bridge needing a version-matched wasm_exec.js) and wasip1 (a socket-less WASI syscall surface) — and the build pipeline must produce content-addressed, stripped, pre-compressed, version-matched artefacts for whichever you ship. On the server, wazero gives a pure-Go runtime whose Runtime/CompiledModule/Module lifecycle you exploit by compiling once and instantiating per call; its real production weight is in the ABI (a bounds-checked numbers-and-memory boundary via go:wasmexport/go:wasmimport), the three enforcement mechanisms every untrusted guest needs (page cap, interruptible deadline, instruction budget), and the deny-by-default WASI capability surface. In the browser, streaming compilation demands application/wasm and composes cleanly with transparent Content-Encoding. Tie it together with a hermetic, toolchain-pinned pipeline and an operational playbook that maps each opaque symptom — blank page, corrupt decode, latency cliff, host OOM, hung request — to a known contract violation and its fix.
In this topic