Using Go with Wasm and Web Workers

These are some notes for myself about trying to use Wasm and Web Workers to achieve some level of parallelisation in the browser. This isn't meant to be a comprehensive tutorial, but there are so many broken tutorials or half bits of documentation out there, I thought I should leave myself a note here. This is just the result of an afternoon of spelunking to try and work out how to do this, and should not be considered comprehensive.

Example

If you're viewing this page directly (rather than via an RSS reader) and your browser supports Wasm, then below you should see a Mandelbrot fractal render into place above, with different chunks appearing at different points (and on Safari you might see some banding, which is it failing to align the canvas tiles properly rather than being an issue with the fractal generation). Each tile is being rendered in a parallel in Go code, using a mix of the aforementioned technologies. The source code for this can be found here.

The slightly fun thing, which may vary for you depending on the speed of your machine, how many cores you have, the browser you're using etc. is that you can see the tiles with more black in them render more slowly, rather than the tiles render in order. This is some indication of parallelism: the black part of a fractal is the slowest part to render (the black actually is the algorithm giving up after it his a maximum number of iterations), so the fact that all the lighter tiles show up first and the others take longer is a nice indicator that they're not just being run in order.

Context

Web Assembly, aka Wasm, is a way to write code in another language than Javascript and have it run in a browser. That's the sales pitch anyway, but it's a bit more like writing plugins[1] for a web page, as you still need Javascript to act as the loader for the Wasm blob, and it has a constrained set of ways it can interact with the page (you can work with the DOM, but for Canvas drawing you'll need to have some Javascript code for that also). Your Wasm components are also constrained by the Wasm runtime, which means you won't get all the features of your language that you're used to. In particular (related to my interests), the Wasm virtual machine is still running in a similar context to Javascript in the browser, so can only be single threaded, as exemplified by this quote from the most recent Go update on the topic:

While Go 1.24 has made significant enhancements to its Wasm capabilities, there are still some notable limitations.

Wasm is a single-threaded architecture with no parallelism. A go:wasmexport function can spawn new goroutines. But if a function creates a background goroutine, it will not continue executing when the go:wasmexport function returns, until calling back into the Go-based Wasm module.

That's not to say there aren't benefits from being able to use a language other than Javascript in the browser, but it's important to understand its constraints.

The second bit of context here is related to that lack of parallelism, which is clearly desirable for certain applications. There is now a model in Javascript to get a level of parallelism, which is Web Workers. In Javascript you can now instantiate a Javascript script as a "worker", aka a thread of execution, to which you can pass messages asking it to do some work and receive a message back when it's got something to tell you. A worker is single threaded again, and if you ask it to do multiple things it'll just queue them up, but you can instantiate multiple workers and ask them each to do a thing, and now you're starting to find a level of parallelism.

The Web Worker model is only available to Javascript, but you can from your worker Javascript instantiate a Wasm component, and thus we can now have a somewhat convoluted way to run non-Javascript code in the browser in parallel.

At least, that's the theory, so now let's have a go with Go.

Using Go for Wasm

Which way to Go

Know that whatever I write here will age poorly. I'm having to write my own notes here because notes written by others have also inevitably decayed. It appears that Wasm support is still at the stage where people are working out the best ways to do things, and so there's a lot of posts that either don't work any more or are conflicting.

The waters are further muddied in the Go world as there's two toolchains which use slightly different options and syntax for supporting Wasm. There's the main Go toolchain, and then there's TinyGo, which is Go aimed at embedded systems. Both of these toolchains support Wasm, but it looks like TinyGo tried to do a better job, then main Go caught up, but with a slightly different syntax for things, and so you may have either Go or the corresponding Javascript code that works for one and not the other (the Javascript has to change due to slightly differences in the exports the Wasm modules make from the two different toolchains).

TinyGo seems like a good choice: being aimed at embedded systems the runtime library is smaller than the regular Go runtime, and so your compiled Wasm blob will be smaller with TinyGo than Full Fat Go™. However, TinyGo's toolchain (at least for Wasm) relies on the regular Go toolchain at points, but is currently lagging behind on support:

$ GOOS=js GOARCH=wasm tinygo build -o main.wasm ./main.go
requires go version 1.19 through 1.23, got go1.24

And given I had 1.24 installed on my machine and I didn't want to mess about with it, the rest of this document will be based on using the main Go toolchain.

A minimal Go blob

I want to title this section "a minimal Go module", as from a Wasm point of view that's how I see the result, but the term module in Go has a very specific meaning which is not the same, and so I'll keep using the term blob.

If we imagine a Wasm blob in Go that exports a function to add two numbers, we can write that thus using the latest version of Go:

package main

//go:wasmexport add
func add(a int32, b int32) int32 {
return a + b;
}

func main {}

Three things to note here:

There is a comment annotation that sort of exports the method in the Javascript world.

Only certain types are allowed for Wasm exposed functions, as Wasm has a limited set of datatypes it supports. You can see the list of supported Go types here.

You still have to a main function, though it can be totally empty if you're exporting things this way.

To clarify the uncertainty in that first point. If you use the function annotation here, then your method isn't added to the global Javascript namespace, but rather is in a list of functions in the instance object for your Wasm blob in the Javascript world (see next section). You can use the older style of pushing your function into the Javascript namespace if you like also:

package main

import "syscall/js"

func add(a int, b int) int {
return a + b;
}

func main() {
c := make(chan struct{}, 0)

js.Global().Set("add", js.FuncOf(func(this js.Value, args []js.Value) any {
return add(args[0].Int(), args[1].Int())
}))

<-c
}

I've not seen in the documentation why it is that for the new style of annotation you don't need anything in main, compared to this older style. The advantage of this is that now add is just a thing you can call from any Javascript as it's in the global namespace, assuming you think it's an advantage to do so. But given I prefer to have more control over where things appear, I'll just stick with the new style of coding.

It's worth noting that if you use TinyGo then that had the annotations before the main Go compiler, and uses a slightly different syntax for them, so as far as I can tell you need to code for one or the other currently, you can't code for both. I believe TinyGo will also convert different types (based on an example I was reading). I assume at some point they'll align, but for now it feels like you're going to write code for either the Full Fat Go™ toolchain or the TinyGo toolchain, rather than just you're writing Go for Wasm.

You at least do compile them the same way:

$ GOOS=js GOARCH=wasm go build -o main.wasm ./main.go

The one thing to note there is that there is a second GOOS target, wasip1, which you can use if you don't want to use the browser but instead are targeting a standalone Wasm runtime like wasmtime.

Loading the Wasm blob

Now we have some Go code compiled into a Wasm blob, we want to load it into the browser. To do that with Go you first want to locate the helper Javascript file that comes with the Go toolchain. You can copy that into your project directory like so:

$ cp `go env GOROOT`/lib/wasm/wasm_exec.js .

Then you can load and call your Wasm thus:






const go = new Go();
let inst;
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
inst = result.instance;
go.run(inst);
console.log(inst.exports.add(3, 4));
}).catch((err) => {
console.error(err);
});






The important thing to note here is that loading Wasm is an asynchronous operation: until that go.run(inst) line has run, you can't assume your Wasm code is accessible, so you should default to having any controls on your page related to the Wasm plugin disabled and only enable them in the then block after loading the Wasm blob. You need to doubly pay attention to this with Web Workers, as we'll see.

Note also the inst.exports.add call - that's because I used the annotation to publish my interface. If I'd used the js.Global().Set("add"... technique then I could just have called add directly.

One gotchya you will face at this point is that if you have a bug in your Wasm code, in the browser console it'll appear as an error in wasm_exec.js rather than you getting anything useful about your Go code.

Web Workers

Just Web Workers

Javascript has always been single threaded, and Wasm follows in that model. But for the projects I have in mind around geospatial work, I'm interested in can we run things in parallel on the client side. Thankfully we now have Web Workers, which is a way to set up one or more worker threads. These threads are just Javascript modules (files) that you can ask to be set up as a worker, and they have a message queue to which you can request they do work, and another queue through which they can send responses. The workers themselves are also single threaded, so if you send them two requests they will service one fully before they service the next one.

The API for this is really quite simple and clean. You first create your worker logic in a Javascript file:

// worker.js

// something to do some work
function add(a, b) { return a + b }

onmessage = ({ data }) => {
const { action, payload } = data;
switch (action) {
case "add":
const { x, y } = payload;
const res = add(x, y);
postMessage({ action: "result", payload: res });
break;
default:
throw (`unknown action '${action}'`);
}
};

And then in your main Javascript file you write:

const worker = new Worker("worker.js");
worker.onmessage = ({ data }) => {
let { action, payload } = data;
switch (action) {
case "result":
console.log("we got a result: ", payload);
break;
default:
console.error(`Unknown action: ${action}`);
}
};

...

worker.postMessage({action: "add", payload: {x: 4, y: 4}});

As mentioned before, a worker can not itself do parallel work, but you can call new Worker() multiple times to create many worker threads that you then send tasks to.

Web Workers with Wasm

Finally we can pull this together. The basic architecture here is that we load a web worker that in turn loads a Wasm module. This is simple enough, with the one caveat that you need to have the web worker tell you when it's ready due to that aforementioned loading delay for Wasm blobs (or, I guess, you could have requests fail before then, but that's now how I roll).

So, our worker now looks something like this:

// worker.js

importScripts("wasm_exec.js");

const go = new Go();
let exports;
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
exports = result.instance.exports;
go.run(result.instance);
postMessage({ action: "ready", payload: null });
}).catch((err) => {
console.error("Worker failed to load WASM module: ", err)
});

onmessage = ({ data }) => {
const { action, payload } = data;
switch (action) {
case "add":
const { x, y } = payload;
const res = exports.add(x, y);
postMessage({ action: "result", payload: res });
break;
default:
throw (`unknown action '${action}'`);
}
};

Note that extra postMessage in the success handler for loading the Wasm blob, that is there to tell the main Javascript code that this worker is now actually ready to do something.

In the main code we can have something like:

const worker = new Worker("worker.js");
worker.onmessage = ({ data }) => {
let { action, payload } = data;
switch (action) {
case "ready":
worker.postMessage({action: "add", payload: {x: 4, y: 4}});
case "result":
console.log("we got a result: ", payload);
break;
default:
console.error(`Unknown action: ${action}`);
}
};

This is a bit of a silly artificial example I've used in the code snippets here, but you can see a real working version for generating that opening fractal using 12 parallel Wasm workers here.

I found myself shuddering slightly at this apparent return to Java applets and ActiveX. At least the security model is better thought out this time around it seems.
↩︎︎