42.4 Using TinyGo for WebAssembly Targets
Right, so you want to run Go on a web page. Not a server, not a CLI tool, but in the browser. You’ve heard WebAssembly (WASM) is the magic that makes it happen, and you’ve heard TinyGo is the key to doing it without shipping the entire Go runtime stuffed inside a clown car. You’re right on both counts. Let’s get into it.
The core idea is brilliantly simple: you write your Go code, TinyGo compiles it to a .wasm file, and you load that into your browser with a bit of JavaScript hand-holding. The magic, and the friction, lives in the interaction between your Go code and the JavaScript world it’s now imprisoned in.
The Absolute Bare Minimum Setup
Let’s start with the “Hello, World” of WASM: making a console log appear from Go. First, ensure you’ve installed TinyGo. The regular go toolchain can technically compile to WASM, but the resulting files are comically large for anything real. TinyGo exists to fix that.
Create a simple Go file, let’s call it main.go:
// main.go
package main
func main() {
println("Hello from the WebAssembly side!")
}
Yes, that’s it. Now, compile it using TinyGo:
tinygo build -o main.wasm -target wasm main.go
This generates a main.wasm file. Now, you need an HTML file to host it and the necessary JavaScript glue code to instantiate the WASM module. TinyGo thankfully provides this. Grab wasm_exec.js from the TinyGo distribution (it’s usually in $TINYGOROOT/targets/):
<!-- index.html -->
<html>
<head>
<meta charset="utf-8"/>
<script src="wasm_exec.js"></script>
<script>
// Instantiate the WebAssembly module
const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
</script>
</head>
<body></body>
</html>
Serve this from a local web server (python -m http.server will do) and open it. Crack open your browser’s developer console. There it is: your message. It feels like a parlor trick, but it’s the foundation. That println doesn’t call a native OS function; it’s shimmed through the wasm_exec.js glue code to call console.log.
Talking to JavaScript: The syscall/js Package
Printing is cute, but you want to manipulate the DOM, handle events, and actually do something. This is where the syscall/js package comes in. It’s your direct line to the JavaScript universe. It’s also a bit… verbose. The designers were clearly more focused on completeness than ergonomics. Let’s be honest, it feels like writing Go through a JavaScript-shaped straw.
Here’s how you’d add a paragraph to the document body:
// main.go
package main
import "syscall/js"
func main() {
// We're not just printing, so we need to keep the app running.
// Channel to block forever.
done := make(chan struct{}, 0)
// Get the document body
document := js.Global().Get("document")
body := document.Get("body")
// Create a new <p> element
pElem := document.Call("createElement", "p")
pElem.Set("innerHTML", "This paragraph was injected by Go!")
// Append it to the body
body.Call("appendChild", pElem)
// Wait forever so our Goroutine doesn't exit
<-done
}
Compile and run this again. You’ll see the paragraph. The key thing to understand is that every js.Value (the return type of Get, Call, etc.) is a wrapper around a JavaScript reference. You’re not working with Go structs; you’re passing messages back and forth across the WASM boundary. Every one of these calls has a cost.
Handling Callbacks and Events
Static content is boring. Let’s handle a click event. This is where you see the callback-centric nature of JavaScript clash beautifully with Go’s concurrency model.
package main
import "syscall/js"
func main() {
done := make(chan struct{}, 0)
// Create a function to be called from JS
callback := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
js.Global().Get("console").Call("log", "Button clicked from Go!")
return nil
})
// Defer its release to avoid memory leaks. This is CRUCIAL.
defer callback.Release()
// Get the document and body
document := js.Global().Get("document")
body := document.Get("body")
// Create a button
button := document.Call("createElement", "button")
button.Set("textContent", "Click Me (from Go!)")
// Add the event listener. The callback is our Go function.
button.Call("addEventListener", "click", callback)
body.Call("appendChild", button)
<-done
}
The js.FuncOf is the magic here. It wraps a Go function and exposes it to JavaScript as a callable function. The huge pitfall here is memory management. You must call Release() on that function when you’re done, or it will leak memory. The garbage collector can’t see across the WASM-JS boundary to know if JavaScript is still holding a reference. It’s manual. It’s annoying. It’s your job.
Best Practices and The Size Problem
- Minimize JS-Go Calls: Every call across the boundary is slow. If you need to read multiple properties from a JS object, get the whole object once as a
js.Valueand then work on it in Go, don’t make a round-trip for each property. - String Conversions are Expensive: Passing strings back and forth isn’t free. Be mindful of the data you’re shoveling.
- The
-no-debugFlag: When you compile for production, usetinygo build -o main.wasm -target wasm -no-debug .... This strips debug information and can significantly reduce the final.wasmfile size. - What Can’t You Do? Networking is a big one. Attempts to use
net.Httpwill fail spectacularly because the browser’s security sandbox doesn’t allow raw TCP sockets. You have to use JavaScript’sfetch, which means, you guessed it, moresyscall/jsshimming.
So, is it worth it? For a complex web app, you’re probably better off with TypeScript. But for porting a specific, compute-heavy Go library to the web, or for building tools where Go’s strengths (concurrency, ease of writing) outweigh the interfacing awkwardness, TinyGo on WASM is a fantastically powerful tool. Just know you’re signing up for a life of being a translator between two very different languages.