How to Build a Web App Frontend in Go with Wasm

Web
Use the TinyGo compiler with the --target wasm flag to build Go frontend applications that run in the browser as WebAssembly.

When Go meets the browser

You've spent the last month writing Go backends. The syntax feels right. Error handling is explicit. The tooling is fast. Now you're staring at a React component tree and thinking there has to be a better way. You want to write the UI in Go too. You try go build and point it at the browser, but the standard toolchain refuses. Go doesn't compile to WebAssembly out of the box. The standard compiler targets native binaries. To run Go in the browser, you need a different compiler that understands the JavaScript environment and the WebAssembly runtime.

The compiler gap

Go's standard toolchain produces binaries for operating systems like Linux, Windows, and macOS. It generates ELF files, Mach-O files, or PE files. It does not generate WebAssembly modules. It does not generate the JavaScript glue code needed to talk to the DOM or handle browser events.

WebAssembly is a binary format that runs in the browser alongside JavaScript. It is fast, portable, and sandboxed. Go code can compile to Wasm, but the standard compiler lacks the runtime support for the browser environment. TinyGo is a lightweight Go compiler optimized for small targets like microcontrollers and Wasm. It supports the GOOS=js and GOARCH=wasm targets. When you compile with TinyGo, it generates a .wasm file and a small JavaScript loader that bridges Go functions to JavaScript. The browser loads the Wasm module, and your Go code runs inside the sandbox, calling back into JavaScript when it needs to touch the page.

TinyGo compiles Go to Wasm. The standard toolchain does not.

Minimal example

Here's the simplest goroutine: spawn one, send a message, close the channel. In the Wasm world, the pattern is similar but the bridge is different. You write Go code that calls into JavaScript. You compile with TinyGo. You embed the result in HTML.

Create a main.go file. This code waits for the page to load, then updates a div with text from Go.

package main

import (
	"syscall/js"
)

// main runs when the Wasm module initializes.
func main() {
	// Block the main goroutine so the Wasm module stays alive.
	done := make(chan struct{}, 1)

	// Wait for the DOM to be ready before accessing elements.
	js.Global().Call("addEventListener", "load", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		// Find the element by ID and update its text content.
		js.Global().Get("document").Call("getElementById", "output").Set("textContent", "Hello from Go!")
		done <- struct{}{}
		return nil
	}))

	<-done
}

Create an index.html file. This file loads the TinyGo runtime and instantiates the Wasm module.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Go Wasm</title>
</head>
<body>
    <!-- Container for the Go output. -->
    <div id="output">Loading...</div>

    <!-- The loader script generated by TinyGo handles Wasm instantiation. -->
    <script src="tinygo_wasm_exec.js"></script>
    <script>
        // Instantiate the Wasm module and attach it to the global scope.
        const go = new Go();
        WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
            go.run(result.instance);
        });
    </script>
</body>
</html>

Build the module with TinyGo. The --target wasm flag tells TinyGo to generate a Wasm binary for the browser.

tinygo build -o main.wasm -target wasm main.go

Serve the files. Browsers block Wasm modules loaded from the file system due to CORS restrictions. Use a local server.

# TinyGo includes a simple server for development.
tinygo serve

Open the browser to the local address. The div updates to "Hello from Go!".

How the bridge works

The syscall/js package is the bridge between Go and JavaScript. It exposes the JavaScript runtime to Go code. You can call JavaScript functions, create objects, and handle events. The bridge marshals values between Go and JavaScript automatically for basic types. Integers map to numbers. Strings map to strings. Slices map to arrays. Structs map to objects.

js.Value is a handle to a JavaScript value. It is not the value itself. It points to a value in the JavaScript heap. When you call methods on a js.Value, you are sending messages to the JavaScript engine. The engine performs the operation and returns the result.

// GetValue demonstrates accessing properties and calling methods.
func GetValue() {
	// Get the global window object.
	window := js.Global()

	// Retrieve the navigator property.
	navigator := window.Get("navigator")

	// Call the userAgent property as a getter.
	userAgent := navigator.Get("userAgent").String()

	// Call the alert function with a string argument.
	window.Call("alert", userAgent)
}

The bridge has overhead. Every call crosses the boundary between Go and JavaScript. This involves marshaling arguments, invoking the JavaScript function, and unmarshaling the result. Avoid tight loops that cross the bridge. Batch operations when possible. Compute in Go, then update the DOM once.

js.Value is a handle. Treat it like a pointer to JavaScript land.

Realistic example

A counter app shows event handling, state updates, and function wrapping. This example increments a count when a button is clicked. It also demonstrates checking argument length and releasing values to avoid memory leaks.

package main

import (
	"syscall/js"
)

// increment handles the button click event.
func increment(this js.Value, args []js.Value) interface{} {
	// Check argument count to prevent runtime panics.
	if len(args) != 0 {
		// JavaScript called with unexpected arguments.
		return nil
	}

	// Retrieve the count element.
	countEl := js.Global().Get("document").Call("getElementById", "count")

	// Parse the current count from text content.
	count := countEl.Get("textContent").Int()
	count++

	// Update the text content.
	countEl.Set("textContent", count)

	// Release the handle when done to free memory.
	countEl.Release()

	return nil
}

// main sets up the event listener and blocks.
func main() {
	// Block the main goroutine to keep the module alive.
	done := make(chan struct{})

	// Wait for the DOM to load.
	js.Global().Call("addEventListener", "load", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		// Get the button element.
		btn := js.Global().Get("document").Call("getElementById", "btn")

		// Wrap the Go function in a JS function.
		// Store the Func to prevent garbage collection.
		clickHandler := js.FuncOf(increment)
		btn.Call("addEventListener", "click", clickHandler)

		// Release the button handle.
		btn.Release()

		return nil
	}))

	<-done
}

The js.FuncOf wrapper creates a JavaScript function that calls back into Go. If you don't store the returned js.Func, the Go garbage collector might reclaim it, breaking the callback. Store it in a variable or a global map. The Release method tells the bridge that Go no longer needs the handle. This allows the JavaScript garbage collector to clean up the underlying value.

Block main. The goroutine must live as long as the page.

Pitfalls and runtime behavior

The syscall/js package is powerful but unforgiving. The bridge enforces strict rules. Violating them causes runtime panics.

If you call a method on a nil js.Value, the runtime panics with js: nil Value. This happens when you access a DOM element that doesn't exist or when a JavaScript function returns undefined. Always check for nil before calling methods.

If you pass a Go function to JavaScript and JavaScript calls it with the wrong number of arguments, the runtime panics with js: wrong number of arguments. You must check len(args) inside your handler. The args slice contains the arguments passed from JavaScript.

If you forget to wait for the load event, you might get js: undefined when accessing DOM elements that haven't rendered yet. The DOM is not ready until the browser parses the HTML. Use the load event or defer scripts.

Memory leaks happen when you hold references to js.Value objects without releasing them. The JavaScript garbage collector cannot reclaim values that Go is referencing. Call Release when you're done. Conversely, if you release a value too early, you might access a dangling handle. Keep references alive as long as you need them.

TinyGo doesn't support all Go packages. net/http works but is limited. database/sql is out. reflect is limited. The compiler optimizes for size and speed. It strips unused code. It doesn't include the full standard library. Check the TinyGo documentation for supported packages.

Cross the bridge sparingly. Every call costs memory and time.

Decision matrix

Use TinyGo Wasm when you need to share logic between Go backend and frontend. Use TinyGo Wasm when you want to write UI code in Go and avoid JavaScript entirely. Use TinyGo Wasm when you have compute-heavy tasks like image processing or cryptography that benefit from Wasm performance. Use standard Go with html/template when you want server-side rendering and faster initial load times. Use a JavaScript framework when you need access to the vast ecosystem of UI libraries and tools. Use plain JavaScript when the app is simple and adding a build step isn't worth it.

Share logic, not just syntax.

Where to go next