The sandbox without the overhead
You have a Go service that needs to run third-party logic. Maybe it is a pricing calculator, a data transformation script, or a rule engine that changes weekly. You could spin up a separate process for each plugin and deal with inter-process communication. You could embed a full scripting runtime and fight garbage collection pauses. Or you could load a WebAssembly binary directly into your Go process, run it in a memory sandbox, and call it like a normal function. Wazero makes that last option the default.
How Wazero actually works
WebAssembly is a portable binary format designed to run safely in a sandbox. Think of it like a shipping container. The container holds the code, but it has no doors, no windows, and no direct line to the outside world. It can only interact with what you explicitly hand it. Wazero is the crane that lifts that container into your Go program, unpacks it into a controlled memory space, and lets you call its exported functions without ever leaving your process.
The library splits work into two distinct phases: compilation and instantiation. Compilation turns the raw Wasm bytes into a cached, executable representation. Instantiation allocates the sandbox memory, sets up imports, and prepares the module for execution. This split matters because compilation is expensive. You compile once, then instantiate many times across different requests or goroutines. The compiled module is immutable and safe to share across goroutines. The instance is not. Each instance gets its own isolated heap and stack. Two instances of the same module cannot accidentally read each other's variables.
Compile once, instantiate many times. Treat the compiled module as a read-only template.
A minimal plugin in action
Here is the simplest way to load a Wasm module, call an exported function, and read the result.
package main
import (
"context"
"fmt"
"log"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
)
func main() {
// Context carries cancellation and deadlines into the runtime
ctx := context.Background()
// Create a runtime that manages Wasm modules and memory
r := wazero.NewRuntime(ctx)
// Ensure the runtime releases all compiled modules and memory on exit
defer r.Close(ctx)
// Compile the raw Wasm bytes into a reusable, cached representation
wasm, err := r.CompileModule(ctx, []byte(`
(module
(func $add (param $a i32) (param $b i32) (result i32)
local.get $a
local.get $b
i32.add
)
(export "add" (func $add))
)
`))
if err != nil {
log.Fatalf("compilation failed: %v", err)
}
// Instantiate creates a fresh sandbox with its own memory space
instance, err := r.InstantiateModule(ctx, wasm, wazero.NewModuleConfig())
if err != nil {
log.Fatalf("instantiation failed: %v", err)
}
// Close the instance to free its linear memory and state
defer instance.Close(ctx)
// Retrieve the exported function by its string name
fn := instance.ExportedFunction("add")
if fn == nil {
log.Fatal("exported function 'add' not found")
}
// Call passes Go values into Wasm and returns results as uint64
result, err := fn.Call(ctx, 10, 20)
if err != nil {
log.Fatalf("call failed: %v", err)
}
fmt.Printf("Result: %d\n", result[0])
}
The code starts by creating a context.Context. Wazero expects context as the first parameter in almost every method. This follows the standard Go convention for passing request-scoped values and cancellation signals. The runtime itself is a factory and cache. When you call CompileModule, Wazero parses the Wasm binary, validates its structure, and produces a compiled module object. This step happens once. If you are serving thousands of requests, you compile the plugin at startup and reuse the compiled object.
Instantiation is where the sandbox actually comes alive. InstantiateModule allocates linear memory, initializes the data segments, and binds any host functions you configured. Each instance gets its own isolated heap. Two instances of the same module cannot accidentally read each other's variables. When you call ExportedFunction, you get a typed handle. Calling it with fn.Call marshals Go values into Wasm's i32 or i64 format, executes the bytecode, and returns an array of uint64 results. The library handles the conversion automatically.
Talking back to the host
Plugins rarely work in a vacuum. They need to read configuration, query a database, or write logs. Wazero solves this with host functions. You write a Go function, register it under a namespace, and the Wasm module imports it like a built-in primitive.
// Host function signature must match Wazero's expected callback shape
// Context first, then the module instance, then parameters
func writeLog(ctx context.Context, mod api.Module, ptr uint32, len uint32) {
// Get the linear memory view from the Wasm instance
mem := mod.Memory()
// Read the string slice directly from Wasm memory without copying
msg := mem.ReadAsString(uint32(ptr), uint32(len))
fmt.Println("Plugin output:", msg)
}
func setupHostFunctions(r wazero.Runtime) wazero.ModuleConfig {
// Create a namespace called "host" for plugin imports
config := wazero.NewModuleConfig()
// Bind the Go function to "host.log" so Wasm can import it
config = config.WithFunctions("host", "log", writeLog)
return config
}
The Wasm module declares the import with (import "host" "log" (func (param i32 i32))). When the plugin calls log, Wazero intercepts the call, passes control to your Go function, and resumes execution when it returns. Notice the context.Context and api.Module parameters. Wazero injects them automatically. You do not write them in the Wasm signature. They exist purely to give your host function access to cancellation signals and the current memory space.
Memory access is where Wazero shines. Instead of copying strings back and forth, you read directly from the Wasm linear memory using mem.ReadAsString. This zero-copy approach keeps latency low. The tradeoff is that you must respect the sandbox boundaries. You cannot pass a Go pointer into Wasm. You cannot hand Wasm a *bytes.Buffer. Everything crosses the boundary as raw bytes or integers.
Host functions are bridges, not backdoors. Keep the interface narrow and type-safe.
Where things go wrong
The sandbox is strict, and the compiler will catch mistakes early. If you try to call a function that does not exist, ExportedFunction returns nil. Calling fn.Call on a nil handle panics. Always check for nil before invoking.
Type mismatches surface immediately. Wasm functions expect i32 or i64 parameters. If you pass a float or a string without converting it to a memory pointer, the runtime rejects the call with invalid parameter type for call. The same happens if you return the wrong number of values. The runtime expects exactly the number of results declared in the Wasm signature.
Resource leaks are the most common runtime bug. Every CompileModule and InstantiateModule call allocates memory. If you forget to call Close on the runtime or instance, the memory stays pinned. The garbage collector cannot reclaim Wasm linear memory because it lives outside Go's heap. You will see steady memory growth until the process hits its limit. Always pair NewRuntime and InstantiateModule with defer ... Close(ctx). This matches the standard Go error-handling pattern: allocate, check, defer cleanup, proceed.
Untrusted binaries introduce another layer of risk. Wazero validates the module structure during compilation, but it does not sandbox against infinite loops or excessive memory allocation by default. If you load plugins from the internet, set a memory limit using wazero.NewModuleConfig().WithMemoryLimits(1024, 1024*1024) and attach a context with a timeout. The compiler will reject malformed binaries with invalid Wasm binary: unexpected end of data, but a valid binary can still exhaust your CPU. Context cancellation is your safety net. When the context expires, Wazero aborts the execution and returns a cancellation error.
Wasm memory lives outside the garbage collector. Close your instances or watch your RSS climb.
When to reach for Wasm plugins
Use Wazero when you need to run untrusted or third-party code inside your Go process without spawning separate binaries. Use a separate process with os/exec when the plugin requires full system access, native libraries, or its own network stack. Use a pure Go interface when the plugin authors can compile their code into a shared library or when you want to avoid the overhead of a virtual machine. Use a database or external service when the logic is stateful and needs to survive process restarts. Use plain sequential code when you do not need extensibility: the simplest thing that works is usually the right thing.
Sandbox the code, not the developer. Pick the isolation boundary that matches your threat model.