Integrating Go with JavaScript through WebAssembly and Web Workers
Increasingly, processing in web applications is moved to the client side. Is JavaScript always the right tool for the job? I don’t believe it’s always the answer, especially when we can take advantage of tools such as WebAssembly. In this article, we’ll take a look at integrating Golang compiled down to WebAssembly with an SPA demo through a Web Worker.
Heads up: check out the code discussed in the article at https://git.sr.ht/~paweljw/go-wasm-worker-example/tree/demo-1/.
Why WebAssembly?
There are several great reasons to use WebAssembly. Here are a few.
No new languages in the stack
Multiple strongly-typed languages support compiling to WebAssembly. There are methods of compiling to WASM for C/C++, C# (albeit the official support seems a tad lacking), Rust, and of course Go.
This means that whatever a given group of developers is comfortable with already - they can use. If there isn’t already a language like that in the group’s vocabulary, I strongly believe Go makes for a compelling candidate. With a relatively small language footprint (meaning there isn’t much to learn), a rich standard library, and Google-level battle testing, what’s not to love?
Well, a few things. But we’ll get into them later on.
Security
WebAssembly’s security model is quite complex. It boils down to this: it’s hard to mess with your users by deploying buggy code, and it’s hard to mess with your code from the outside. It’s a sober, level-headed approach to web application security, which JS as it stands - in my opinion - doesn’t and cannot support.
Speed!
WebAssembly is faster than JavaScript. Well… at least it is supposed to be. There are sources saying it’s not faster than JavaScript. You may note that’s quite a few sources. You’d be correct in so noting.
Here’s my take. JavaScript has become, for better or for worse, a cornerstone of the modern web. The engine builders are extremely clever people who look at what’s being built and used on the web, and optimize accordingly. JS has been around for 27 years; WebAssembly is much younger.
If, at present, WASM is about as fast as JavaScript, then the other two upsides are - for me - enough to commit to WebAssembly. Especially since it can be expected to be optimized and improved only if it sees use in the real world.
How does Go do WebAssembly?
In a word: it’s a bit weird.
The Go compiler supports WASM directly, via GOOS=js GOARCH=wasm
environment variables (YMMV on Windows systems, I don’t know
how it’s done over there; sorry). But the WASM produced needs to be ran in a special way. Go distributes its own pseudo-runtime -
it can be found under go/misc/wasm/wasm_exec.js
in your Go distribution. This runtime is obligatory, and it has a non-trivial size.
Aside from the loading time implications of adding a whole separate file, I would expect this has a performance impact. I haven’t benchmarked this vs other solutions yet; it’s definitely something I’d like to explore in the future though.
Speaking of loading time implications - it’s worth noting that the .wasm
files produced by the Go compiler are… rather large. A simple hello world equivalent we’ll be
discussing below clocked in at approximately 2.5MB. Yes, that’s megabytes. In web terms, that’s absolutely humungous.
This may be due to using large libraries - I expect import "log"
is not free. I’d like to experiment more with this
and see whether importing libraries is indeed costly, or are large binaries just to be expected when compiling Go to
WASM in the future.
Wait, so why did you pick Go for this?
I like Go and I don’t like any other language on the supported list. Oh, Rust is fine, I suppose, before any Crustaceans come brandishing claws. I just like Go more. Go doesn’t make me borrow things, and it doesn’t yell at me when I borrow them wrong. 😬
I’ll come back to this issue and review other options there may be to run Go in the browser, such as TinyGo, but these are definitely not “hello, world” material. Neither are, in my opinion, benchmarks. So…
Let’s get on with it
For this demo, I used Vite with the preact-ts
template. I usually use Parcel
for quick-and-dirty demos, but I ran into some issues with it and WebAssembly.
Not that I did not run into issues with Vite. Oooh, foreshadowing.
Vite
Vite pulled me in with its promises of esbuild. I’ve seen some real-world examples - particularly in Ruby on Rails applications - where using esbuild improved both build times and bundle sizes. These examples are private, and I’m not at liberty to share them, but long story short - promising esbuild out of the box was enough to have me try Vite.
Anyhow, on with the show. Let’s review our main.go
first:
1package main
2
3import (
4 "log"
5 "time"
6)
7
8func main() {
9 log.Println("Hello, world")
10
11 c := make(chan struct{}, 0)
12
13 log.Println("WASM Go Initialized")
14
15 for i := 0; i < 10; i++ {
16 log.Printf("Iteration %d\n", i)
17 time.Sleep(time.Second)
18 }
19 <-c
20}
It logs a couple of things, then goes into a loop printing something every second. Notice the chan
trick, too. This ensures
our WASM code continues running - in case we expose any methods to JavaScript that we want to still be available once the “main event”
finishes.
What would a Web Worker want?
…and other exercises in alliteration. But firstly - what even are Web Workers?
Long story short: they’re a way for us to run code off the main thread. Even with WebAssembly, if we ran the code shown above,
those time.Sleep
calls would hang the main thread. Buttons? Unclickable. Browser? Unresponsive. User? Unimpressed1.
So if we want to do things which could cause the main thread to hang, ideally we do them in Web Workers. Note these are not the same as Service Workers - they serve different roles.
To answer the question - a Web Worker would want to hang out in its own file, to be called upon when needed. I built worker.js:
1// "null import" so we can get the wasm execution environment in here
2
3// noinspection ES6UnusedImports
4import * as _ from './wasm/wasm_exec.js?inline'
5
6import wasmUrl from './wasm/lib.wasm'
7
8if (!WebAssembly.instantiateStreaming) {
9 // polyfill
10 WebAssembly.instantiateStreaming = async (resp, importObject) => {
11 const source = await (await resp).arrayBuffer();
12 return await WebAssembly.instantiate(source, importObject);
13 };
14}
15
16const go = new Go();
17let oldLog = console.log;
18
19// send each line of output to the main thread
20console.log = (line) => { postMessage({
21 message: line
22}); };
23
24let mod, inst;
25
26WebAssembly.instantiateStreaming(fetch(wasmUrl), go.importObject).then(
27 result => {
28 mod = result.module;
29 inst = result.instance;
30 go.run(inst).then(() => { console.log = oldLog })
31 }
32);
There’s a lot going on here, so let’s unpack. Firstly, we want to import the wasm_exec.js
file. That ?inline
annotation
is Vite-specific and allows inlining the Go runtime in the worker. I figure, if we’re loading large files, better use fewer2.
Then we import our lib.wasm
… where did that come from? Why, from the magic of adding this into our package.json
:
1{
2 "scripts": {
3 "wasm": "GOARCH=wasm GOOS=js go build -o src/wasm/lib.wasm src/wasm/main.go"
4 }
5}
Vite needs to be told about .wasm files, too, in vite.config.ts
, so we can import
it:
1export default defineConfig({
2 plugins: [preact()],
3 assetsInclude: ['src/wasm/*.wasm'],
4})
5
The next bit is the polyfill. That’s pretty much just in case, and could be omitted on modern browsers. Thanks to importing
wasm_exec.js
, we can now const go = new Go();
. That’s going to be our execution environment. We brazenly replace console.log
with some message-passing shenanigans, and finally run our WASMified go.
Once we’ve got all that, we can ignore this section of the blog post getting ridiculously long, and add this to our main.tsx
:
1import WasmWorker from './worker.js?worker'
2
3const worker = new WasmWorker()
Phew. Save, refresh, and - unless you’re on Chrome - crash.
Module workers work in Chrome, not Firefox (by default)
Importing a worker in Vite makes it a module worker. These have first-party support in
recent versions of Chrome and… yeah, that’s it. I mean, Firefox has support, but you need magic incantations
in about:config
.
Not that big of a deal. Add the magic, save, refresh and… nothing.
Hold on, worker.js
? Aren’t we using TypeScript?
We’re supposed to be using TypeScript. I tried and tried to get worker.ts
to play nicely with everything else. In short -
I failed to achieve progress with it. It may be because I was missing the module workers support flag at that point,
or that I didn’t understand how Vite works with workers yet. It’s something to explore at a later date. For now, I’ll just
keep this one file as .js
. Thanks to wasm_exec.js
this project is not pure TypeScript all the way anyway, so what’s one more file?
Workers communicate with messages
Web Workers cannot interact with the DOM directly. In fact, they can’t do much of anything that isn’t message passing.
That’s what this is about:
1console.log = (line) => { postMessage({
2 message: line
3}); };
Here, we replace console.log
with a call to postMessage
, which will send a message from our worker to the main thread.
The main thread can subscribe to those messages via event listeners. Let’s add one to our main.tsx
:
1const worker = new WasmWorker()
2worker.onmessage = (event: MessageEvent) => {
3 console.log('Worker said:')
4 console.log(event.data.message)
5}
This will grab the incoming message and write it into the console.
Can we use console.log
in Web Workers? Sure we can. Will it work? Exactly as expected. So why on Earth are we doing this?
Because that’s the idea I had for showing off message passing.
In action
{{< video src=“/media/2023-05-06-go-wasm-1.mov” >}}
As we can see, the messages are making it from the WASM context through message passing and into the console. I can still interact with the page - such as click the button to increase the count. (This button comes with the Vite template, too. Such a nice tool.)
Wait, how did log.Println
end up in JavaScript?
You’re an observant one! I like you.
Through some absolutely arcane magic, Go makes stdout
essentially print to the browser’s console. This works with the log
package, fmt
package, println
and pretty much anything else you could imagine. Using other logging libraries which would
print to stdout
, like logrus, would absolutely work as well.
Where do we go from here?
This is a nice quick demo, but it has several downsides. The message-passing example is contrived. The messages themselves
lack pizazz. The execution will continue in the background even if we move to a different tab, consuming precious system
resources for time.Sleep
…
I’m not done putting Google’s tech into my JavaScript - not by a long shot. Join me next week, as I smash protocol buffers,
aka protobuf
s, into this example, make it go to sleep, and do other tricks on command.
Well, maybe not that last one. Or will I? See ya!