WASI Host Runtimes: wasmtime, wazero & Spin
A wasip1/wasm binary is only half the story.
Something must instantiate the module, satisfy WASI imports, and optionally map HTTP triggers or filesystem capabilities.
wasmtime, wazero, and Spin are the three hosts Go teams reach for first - CLI tooling, in-process embedding, and edge HTTP respectively.
Summary
Compile Go with GOOS=wasip1 GOARCH=wasm, then run the artifact through a host that speaks WASI Preview 1.
wasmtime excels at local dev and scripting.
wazero fits Go microservices that load plugins in-process.
Spin packages opinionated HTTP handlers for Fermyon-style edge deploys.
Each host differs in how it grants filesystem, environment, and network capabilities.
Recipe
Quick-reference recipe card - copy-paste ready.
# Dev loop
GOOS=wasip1 GOARCH=wasm go build -o app.wasm .
wasmtime run --env APP_ENV=dev --dir .::/work app.wasm// Embed with wazero (Go host)
wasi_snapshot_preview1.MustInstantiate(ctx, runtime)
mod, _ := runtime.Instantiate(ctx, wasmBytes)
_, _ = mod.ExportedFunction("_start").Call(ctx)When to reach for this:
- Sandboxed plugins inside an existing Go API (wazero)
- Local reproduction of production WASM behavior (wasmtime CLI)
- Edge HTTP microservices with fast cold starts (Spin)
- CI pipelines that execute guest logic without native binaries
- Multi-tenant workloads needing capability-scoped I/O
Working Example
Guest (guest/main.go):
package main
import (
"fmt"
"os"
)
func main() {
name, _ := os.Hostname()
fmt.Println("hostname:", name)
fmt.Println("env:", os.Getenv("GREETING"))
}Build:
GOOS=wasip1 GOARCH=wasm go build -o guest.wasm ./guestwasmtime:
wasmtime run --env GREETING=hello --dir .::/ guest.wasmwazero host (host/main.go):
package main
import (
"context"
"os"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
func main() {
ctx := context.Background()
r := wazero.NewRuntime(ctx)
defer r.Close(ctx)
wasi_snapshot_preview1.MustInstantiate(ctx, r)
cfg := wazero.NewModuleConfig().
WithEnv("GREETING", "hello from wazero").
WithFSConfig(wazero.NewFSConfig().WithDirMount(".", "/"))
wasm, _ := os.ReadFile("guest.wasm")
mod, _ := r.InstantiateWithConfig(ctx, wasm, cfg)
defer mod.Close(ctx)
_, _ = mod.ExportedFunction("_start").Call(ctx)
}Spin (spin.toml excerpt + build):
spin_manifest_version = 2
[application]
name = "go-guest"
version = "0.1.0"
[[trigger.http]]
route = "/..."
component = "guest"
[component.guest]
source = "guest.wasm"
[component.guest.trigger]
route = "/"spin build
spin upWhat this demonstrates:
- wasmtime flags translate directly to capability grants.
- wazero expresses the same grants in Go APIs suitable for long-running servers.
- Spin wires HTTP routes to WASM components without you writing a custom listener.
Deep Dive
How It Works
- All hosts compile/load WASM, allocate linear memory, and bind WASI import functions.
_start(or exportedmainwrappers) runs after instantiation.- Filesystem access is never implicit - hosts map directories into the guest namespace.
- Environment variables and args are host-curated inputs, mirroring container entrypoints.
Runtime Comparison
| Runtime | Language | cgo | Typical use | HTTP story |
|---|---|---|---|---|
| wasmtime | Rust core, multi-language bindings | Optional in bindings | CLI, polyglot platforms | Via WASI HTTP proposals / embed custom |
| wazero | Pure Go | No | Go services embedding plugins | You mount net/http around guest calls |
| Spin | Rust runtime + Go/other guest langs | No for Go guests | Edge microservices | First-class HTTP triggers |
wazero Production Patterns
- Pool runtimes per tenant or reuse one runtime with separate modules per request - measure memory trade-offs.
- Context deadlines on
Callbound CPU time; combine with fuel metering APIs when available. - Close modules after use to release compiled machine code caches in long-running processes.
Go Notes
// Narrow plugin ABI: guest reads stdin JSON, writes stdout JSON.
// Host wraps Call with buffers via ModuleConfig stdin/stdout overrides.Gotchas
-
Missing WASI instantiate in wazero - Forgetting
wasi_snapshot_preview1.MustInstantiateyields obscure import errors. Fix: always register WASI before guest modules. -
Assuming working directory - Guests see only pre-opened paths, not the host CWD. Fix: mount
.explicitly to/or/work. -
Spin networking expectations - Not every Go
net/httpserver compiles to a Spin component without adaptation. Fix: follow Spin Go SDK patterns for HTTP handlers. -
wasmtime version skew - Older hosts lack newer WASI snapshots. Fix: pin wasmtime in CI to match production.
-
Re-instantiating per request without caching - Compilation dominates cold latency. Fix: cache compiled modules; reuse
wazero.CompiledModule. -
Passing sensitive host paths - Over-broad
--dir /::/breaks sandboxing. Fix: mount least-privilege directories per guest.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Wasmer | You already standardize on it | Team wants pure-Go embedder (wazero) |
| Node + experimental WASI | JS shop, tiny scripts | Production Go embedding |
Native plugins (.so) | Maximum performance, trusted code | Untrusted tenant code |
| Firecracker microVMs | Strong isolation beyond WASM | Sub-millisecond cold starts required |
FAQs
Why pick wazero over wasmtime in Go?
wazero is pure Go - simpler static binaries, no cgo, natural context propagation.
wasmtime remains excellent for CLI/dev and polyglot hosts.
Can wazero run multiple guests?
Yes. Instantiate multiple modules on one runtime, or isolate runtimes per tenant for memory separation.
Does Spin require TinyGo?
No. Spin runs gc-compiled wasip1 modules, but size limits may push you toward TinyGo or -ldflags=-s -w.
How do I log from guests?
fmt.Println to stdout/stderr maps to host logs.
Capture in wazero via ModuleConfig stdout writers.
Can guests call host functions?
Yes via custom imports in wazero (DefineModuleBuilder) or wasmtime linker APIs.
Keep the ABI narrow and versioned.
What about networking?
Check each runtime's WASI sockets support.
Spin exposes HTTP at the trigger layer instead of raw sockets for many apps.
How do updates work?
Treat .wasm as immutable artifacts versioned in object storage.
Swap module bytes and re-instantiate; Spin handles via spin deploy.
Is fuel/metering available?
wazero supports execution timeouts via context; advanced metering evolves per release - verify docs for your pinned version.
Can I run the same module in wasmtime and wazero?
Yes if WASI imports match.
Test both in CI when you embed in Go but develop with wasmtime CLI.
What file permissions should guests use?
Use WASI-mapped paths with read-only mounts for inputs and writable mounts only where persistence is required.
Related
- Compiling with GOOS=wasip1 GOARCH=wasm - produce guest artifacts
- Go on WebAssembly: Browser, WASI, and Embedded Paths - WASI vs browser
- WebAssembly & TinyGo Basics - wazero embed example
- WASM Size Optimization: TinyGo vs gc WASM - edge size budgets
- WebAssembly & TinyGo Best Practices - CI matrix with wasmtime
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).