Skip to content

A WebAssembly runtime in pure Go

Run code you didn’t write.

wazy runs WebAssembly inside your Go process. Somebody else’s Rust, C or Zig becomes a byte slice you go:embed and a function you call — sandboxed, with nothing to install at runtime and no cgo.

go get github.com/samyfodil/wazy@latest

guest capabilitiescomponent.Instantiate
filesystemnothing mounted
sockets — tcp, udpunregistered
outgoing httpunregistered
environmentempty
argumentsargv[0] only
stdoutos.Stdout
One line granted stdout. Nothing else was asked for, so nothing else exists.

Small enough to do per request

A sandbox per request is only practical if none of it costs anything. Every marker below is a benchmark in this repository, taken core-pinned on a quiet machine with the comparison interleaved. They were not all taken on the same machine, so each names its own host. The scale is logarithmic.

10 ns100 ns1 µs10 µs100 µs1 ms
Host call, Go ↔ Wasm · Apple M4
48.5 ns
memory.grow, in capacity · i9-12900HK
109 ns
Instantiate a 37 KB module · Apple M4
1.72 µs
Serve a warm wasi:http component · Apple M4
3.37 µs
The same instantiate, on wazero · Apple M4
15.7 µs
Re-instantiate a cached component graph · host not recorded
350 µs

These six live in four different packages; the performance page has the commands that run all of them, the method, and the optimizations measured and rejected.

A plugin contract with types in it

Embedding Wasm normally means fn.Call(ctx, ptr, len), decoding a pair of i32s out of the guest’s memory, and both halves hand-agreeing on layout and on who frees what. wazy runs WebAssembly components: a .wasm that carries its own typed interface, so records, lists, option and result cross as Go values, and a handle to a host object is a lifetime the runtime drops for you.

store.wit
interface kv {
resource bucket {
constructor(name: string);
get: func(key: string) -> option<list<u8>>;
}
variant error {
no-such-bucket,
denied(string),
}
list-keys: func(b: borrow<bucket>)
-> result<list<string>, error>;
}

Declare the contract once.

host.go
// The host half, in ordinary Go. Both arms of the
// result are composites, so each gets a type-table slot.
tbl := component.NewTypeTable()
errRef := tbl.Variant(
component.VariantCaseSpec{Name: "no-such-bucket"},
component.VariantCaseSpec{Name: "denied",
Type: component.Prim("string")},
)
fd := tbl.Func(
[]component.TypeRef{tbl.Borrow(bucketResType)},
tbl.Result(tbl.List(component.Prim("string")), errRef),
)
listKeys := func(_ context.Context, args []component.Value) (
[]component.Value, error) {
b, ok := s.buckets[args[0].(uint32)]
if !ok {
// The err arm, carrying the payload-less first case.
return []component.Value{component.ResultValue{
IsErr: true,
Payload: component.VariantValue{Disc: 0},
}}, nil
}
return []component.Value{component.ResultValue{
Payload: keysOf(b),
}}, nil
}
component.WithImportCustom("example:store/kv", "list-keys",
listKeys, fd, tbl.Resolver())

The other half is anything that compiles to Wasm.

That is walked through here. A binary a stranger produced with cargo build --target wasm32-wasip2 also runs unmodified, on the 114 WASI 0.2 host functions wazy implements.

What it runs

Components stay components: genuine wasm32-wasip2 binaries from rustc and wasm-tools, not flattened to core modules or hand-written .wat.

core wasm 1.0, 2.0
The baseline, on the optimizing compiler and the interpreter alike.
core wasm 3.0
All eight folded-in proposals: tail calls, extended const, exception handling, typed function references, relaxed SIMD, multiple memories, memory64 and garbage collection. Enabled together with api.CoreFeaturesV3; the default stays V2.
wasi 0.1
The preview-1 ABI for core modules, through imports/wasi_snapshot_preview1.
wasi 0.2
The Component Model and 114 host functions across cli, clocks, filesystem, io, random, sockets and http — the last in both directions. Every capability opts in per instantiation.
wasi 0.3 async abi
stream<T>, future<T>, subtasks, cancellation, backpressure and cooperative threads, on goroutines and channels. A parked guest costs one goroutine, and you call an async export exactly like a synchronous one.
wasi-threads spawn
Not implemented. Shared memory and atomics are available through experimental.CoreFeaturesThreads, but a guest cannot spawn OS threads.

Against wazero, in the same runs

wazy started from wazero’s code and still draws on its WebAssembly semantics and its test suites. It kept neither the API nor the architecture, and these are the paths where that shows.

Instantiateper request
9.1×
1.724 µs against 15.74 µs, on a 37 KB TinyGo module. Cheap enough that every request gets a fresh linear memory instead of a pooled one you scrub between calls.
Interruptible loopsWithCloseOnContextDone
12–13×
+5% overhead against +75% on a loop that calls a host function each iteration. A near-empty compute kernel is the worst case at 1.7-2.4x, tunable with WithInterruptCheckInterval. Measured against wazero@main.
Compiled executionmemory-heavy code
up to −18%
string_manipulation −18%, reverse_array −14%, base64 −12%, fibonacci a wash. The advantage tracks memory-access intensity, not arithmetic.
Host callsCallWithStack, gomodule arm
a tie
47.8 ns here, 48.3 ns there, on the gomodule arm both runtimes implement the same way. Neither wins it. What differs is that wazero still ships a reflection-based registration API beside it; wazy deleted that path rather than optimizing it.
Cumulativegeomean, B/op −22.9%
−17.8%
Across internal/integration_test/bench, against the wazero fork point, with upstream wazero as a control in the same runs — its arms stayed flat.

Two orders of magnitude up, a third party measured a 6.5 MB Rust document converter at 4.5× on a 7.5 MB PDF — and wazero winning on a 1 KB input, where instantiation still dominates. Both rows are on the performance page.

Correctness is a pass count, not a claim

Every number here comes from somebody else’s suite. The component harness fails if its known-skip list changes in either direction: a new entry is a regression, a stale one means a gap closed and the list must shrink.

711generated core spec case files, across 13 corpora, with no skip list
31 / 31official Component Model async suites, 0 skipped, 0 failed — one fixture a locally corrected copy of a file broken upstream
7 / 7official Canonical ABI value suites, every vendored module instantiating
3 OSwasi-testsuite on Linux, macOS and Windows, no skipped tests
4fuzz targets, three of them differential against the interpreter

That list is empty: every vendored module of all seven Canonical ABI value suites instantiates. The last four entries — types.11, fused.22, fused.23 and resources.14 — were each a nested component naming a definition an enclosing one owns, and closing them meant modelling the core-module and component index spaces separately. Empty is enforced like any other state: were one of them to come back, the harness would fail. Detail on the conformance page.

Where something else is the right answer

  • cgo, when the library must spawn OS threads of its own, or needs the host machine itself.
  • A container or a microVM, when you need kernel-enforced CPU quotas per tenant. wazy caps memory and enforces a deadline; there is no fuel metering.
  • Starlark or a scripting interpreter, when the code is config-shaped and your authors will not install a Wasm toolchain.
  • Trust, when the code is first-party and what you wanted was extensibility, not safety.

In production

  • taubyte/tau — the open-source platform behind Taubyte clouds runs its WebAssembly workloads on wazy.
  • go-anydoc — Go bindings for a Rust document converter, and the largest published wazy measurement.