Yo dawg I heard you like Wasm…

(Yes, I know that meme is old enough to drive…)

Introduction

Let’s say you’re using Go’s Wasm target to build a module, which is then used in a web app. (If you’re not familiar with Wasm, or Go’s support of it, the Go WebAssembly wiki page is a good starting point.)

So, you have your web app that includes a Wasm module compiled from Go source code. That works great until at some point, in your Go module, you want to add a new feature, and that new feature needs a dependency, and that dependency is implemented as a C library.

Uh-oh.

With a standard compilation target, you could use Cgo to call that compiled dynamic library. There is probably a project on GitHub that provides those Go bindings already.

But for the Wasm target, that obviously won’t work, you can’t use Cgo.

So what can you do?

Well obviously you can try looking for a Go equivalent to that C library. Or you can rewrite that library in Go yourself… or, you know, just ask your favorite LLM to do that, which in many cases may be the best solution as they’re extremely good at porting stuff from one language to another.

Or you could build that library as a Wasm binary too! Now it too can run in the browser! But there’s a new problem… How do you make your Go Wasm module call that dependency’s Wasm module?

Javascript plumbing

In the browser, Wasm modules are isolated and can’t call each other.

That may be resolved in the future via the currently under development WebAssembly Component Model but it seems very early and not at all supported by the browsers.

Your only solution is to use Javascript as a plumbing layer between the two Wasm modules.

Browser JS      ┌────────────────────────────────┐ 
runtime         │             JS app             │ 
                │                                │ 
                │           ┌─────────────┐      │ 
                └───┬───────┼─────────────┼──────┘ 
                    │       │             │       
 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─ ─ ─ ─│─ ─ ─ ─ ─ ─ ─│─ ─ ─ ─ 
Browser Wasm        │       │             │       
runtime             ▼       │             ▼       
                ┌───────────────┐ ┌──────────────┐
                │ Go app (Wasm) │ │ C dep (Wasm) │
                └───────────────┘ └──────────────┘

But that’s not very nice… Your JS app is probably UI code, using a Wasm module expressly to delegate lower level work to Go code… And all of a sudden it also has to do plumbing between dependencies of that Go code? Talk about separation of concerns…

Wasm inception

Wazero is a Wasm runtime written in Go. In pure Go. No dependencies1. Especially no Cgo.

Which means, that runtime itself can be compiled to Wasm.

Therefore, instead of running our C-turned-Wasm library in the browser’s runtime, we could run it inside our main Go app, and it could still be turned into Wasm to run in the browser. A Wasm inception!

Browser JS      ┌────────────────────────────┐ 
runtime         │           JS app           │ 
                └─────────────┬──────────────┘
 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─ ─ ─ ─ ─ ─ ─ ─ ─
Browser Wasm                  │
runtime                       ▼ 
                ┌────────────────────────────┐
                │        Go app (Wasm)       │
                │                            │
                │  ┌──────────────────────┐  │
                │  │    Wazero runtime    │  │
                │  │                      │  │
                │  │  ┌────────────────┐  │  │
                │  │  │  C dep (Wasm)  │  │  │
                │  │  └────────────────┘  │  │
                │  └──────────────────────┘  │
                └────────────────────────────┘

One thing worth noting BTW: being pure Go should be enough to ensure wazero can be compiled to a Wasm binary… But not necessarily that it will actually run! Many Wasm runtimes use a compiler internally, which needs to write and execute machine code at runtime. That’s not possible inside a Wasm sandbox, where memory is strictly data. Wazero, however, also ships an interpreter, which it uses as a fallback on platforms where compiling is not available.

So, could it work? On paper, it should. Let’s try!

Proof of concept

Let’s create a super-advanced math library in Zig:

math.zig
export fn square(x: f32) f32 {
    return x * x;
}

export fn squareRoot(x: f32) f32 {
    return @sqrt(x);
}

And build it, creating a math.wasm binary:

zig build-exe math.zig -target wasm32-freestanding -fno-entry \
    --export=square --export=squareRoot

Now let’s create a Go app that embeds that math.wasm binary, runs it within wazero, and uses square and squareRoot to build a distance function:

main.go
package main

import (
	"context"
	_ "embed"
	"syscall/js"

	"github.com/tetratelabs/wazero"
	"github.com/tetratelabs/wazero/api"
)

//go:embed math.wasm
var mathWasm []byte

func main() {
	// 1. Create a wazero runtime and instantiate the math Wasm module.
	ctx := context.Background()
	r := wazero.NewRuntime(ctx)
	mod, err := r.InstantiateWithConfig(
		ctx, mathWasm, wazero.NewModuleConfig().WithStartFunctions())
	if err != nil {
		panic(err)
	}

	// 2. Get the two exported primitives.
	square := mod.ExportedFunction("square")
	squareRoot := mod.ExportedFunction("squareRoot")

	// 3. Compose them into a distance function exposed to JS.
	js.Global().Set("distance", js.FuncOf(func(_ js.Value, args []js.Value) any {
		dx := float32(args[0].Float() - args[2].Float())
		dy := float32(args[1].Float() - args[3].Float())

		dx2, _ := square.Call(ctx, api.EncodeF32(dx))
		dy2, _ := square.Call(ctx, api.EncodeF32(dy))
		sum := api.DecodeF32(dx2[0]) + api.DecodeF32(dy2[0])

		res, _ := squareRoot.Call(ctx, api.EncodeF32(sum))
		return float64(api.DecodeF32(res[0]))
	}))

	select {}
}

And build that Go program as an app.wasm module:

GOOS=js GOARCH=wasm go build -o app.wasm .

Now let’s use our distance function… Try moving the points!

It doesn’t look like much, but remember that the coordinates of the points go through the browser’s Javascript engine, the Go Wasm module including the Go runtime, the Wazero runtime within it, the Zig Wasm module (multiple function calls!), and out comes the distance!

And it’s still snappy!

Conclusion

While adding a Wasm runtime inside a Wasm module must carry some performance overhead, in practice it’s still usable, as the responsive demo above suggests. It’s an effective way of using C (/Zig/Rust/…) libraries from within a Go Wasm module, without any JS plumbing. The resulting Wasm module in the proof of concept above, including the Wazero runtime, is not even big, weighing <2 MiB after gzip compression.

Of course that example is trivial, but I’ve actually used this technique for a much more complex parsing library, and it holds up well in practice.

Wazero really is a remarkable project, and the fact that its pure-Go implementation naturally handles the Wasm-inside-Wasm case without any extra effort is super cool.


  1. Technically their go.mod lists a dependency on golang.org/x/sys, but that’s close enough. ^