How to Compile Go to WebAssembly (GOOS=js GOARCH=wasm)

Web
To compile Go to WebAssembly, set the environment variables `GOOS=js` and `GOARCH=wasm` before running `go build`, then use a JavaScript runtime like `wasm_exec.js` to execute the resulting `.wasm` binary in a browser.

The browser does not run Go binaries

You have a Go program that parses logs, generates images, or runs a physics simulation. It runs perfectly on your laptop. You want it to run in a browser so your users can process data locally without hitting a server. You run go build, get a binary, and drag it into Chrome. Nothing happens. The browser expects a specific binary format and a specific set of host APIs. Go needs to speak that language.

WebAssembly is a compiled binary format that runs inside a sandboxed virtual machine in modern browsers. It is not a replacement for JavaScript. It is a performance layer that sits alongside it. When you compile Go to WebAssembly, you are asking the Go compiler to translate your code into a format the browser's VM understands. The compiler strips away Linux or macOS system calls and replaces them with a minimal runtime that talks to the JavaScript engine.

The environment variables GOOS=js and GOARCH=wasm tell the compiler exactly what to do. GOOS=js means the host operating system is the JavaScript runtime. GOARCH=wasm means the target architecture is WebAssembly. The compiler generates a .wasm file and expects a JavaScript glue file to bootstrap it. That glue file is wasm_exec.js. It lives in your Go installation directory and provides the missing pieces: standard I/O, goroutine scheduling, and memory management.

Cross-compilation is the key. The Go toolchain does not need a WebAssembly emulator or a separate SDK. It ships with the target definition built in. You are not emulating a browser. You are compiling to a specification that every major browser already implements.

Compile once. Run anywhere the spec exists.

The minimal setup

Here is the simplest build command. It tells the compiler to target the browser and outputs a WebAssembly module.

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

The browser cannot run the .wasm file alone. It needs the Go runtime shim to initialize memory and start the scheduler. Copy wasm_exec.js from your Go installation to your project folder. The path is usually $GOROOT/misc/wasm/wasm_exec.js on Unix systems or %GOROOT%\misc\wasm\wasm_exec.js on Windows.

Create an HTML file that loads the shim first, then fetches and instantiates the WebAssembly module. The JavaScript must set up the import object before the module loads.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Go WASM</title>
</head>
<body>
    <!-- Load the Go runtime shim before any other script -->
    <script src="wasm_exec.js"></script>
    <script>
        // Create the Go runtime environment object
        const go = new Go();
        // Fetch the wasm module and pass the runtime imports
        WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
            .then(result => {
                // Hand control to the Go runtime scheduler
                go.run(result.instance);
            });
    </script>
</body>
</html>

Your Go code needs a main function to serve as the entry point. The syscall/js package bridges Go and the browser. It lets you call JavaScript functions and access DOM elements directly.

package main

import "syscall/js"

func main() {
    // Access the global console object from the browser
    console := js.Global().Get("console")
    // Call the log method with a string argument
    console.Call("log", "Go is running in the browser")
    // Block forever so the wasm instance stays alive
    select {}
}

Keep the shim close to the binary. They are version-locked.

What happens under the hood

When the browser loads the page, it executes wasm_exec.js. That script creates a Go object and prepares an import object. The import object maps WebAssembly imports to JavaScript functions. WebAssembly modules cannot do I/O on their own. They must import functions from the host to read files, print to the console, or allocate memory. The Go object provides those imports.

WebAssembly.instantiateStreaming fetches the binary and compiles it in the background. Once compiled, it hands the instance to go.run. That call starts the Go scheduler. Goroutines begin executing. The main function runs. When main returns, the program exits and the WebAssembly instance tears down. That is why the minimal example blocks with select {}. Without it, the program finishes instantly and the browser reclaims the memory.

The memory model is linear. WebAssembly uses a single contiguous block of memory that grows in pages. Go's garbage collector runs inside that block. When you allocate a slice or a string, it lives in the WASM heap. JavaScript cannot see that memory directly. You must use syscall/js to copy data across the boundary, or use js.Value wrappers that hold references to JavaScript objects.

The scheduler works differently than on a desktop. There is no OS thread pool. The Go runtime multiplexes goroutines onto a single JavaScript event loop. When a goroutine blocks on I/O or waits on a channel, the runtime yields control back to the browser. The browser processes UI events, then calls back into the runtime to resume the goroutine. This cooperative model keeps the UI responsive.

Do not block the main goroutine for long periods. The browser will freeze.

A realistic event handler

Real browser code rarely just prints to the console. It usually responds to user input or updates the DOM. Here is a handler that attaches to a button click, reads a value, processes it in Go, and updates the page.

package main

import (
    "syscall/js"
)

// calculate takes a JS event, reads an input, and updates a span
func calculate(this js.Value, args []js.Value) interface{} {
    // Get the input element by ID from the document
    input := js.Global().Get("document").Call("getElementById", "number")
    // Parse the string value into a JavaScript number
    val := js.Global().Get("parseFloat").Invoke(input.Get("value"))
    // Square the number using native Go arithmetic
    result := val * val
    // Update the result span in the DOM with the new text
    js.Global().Get("document").Call("getElementById", "result").Set("textContent", result)
    // Return nil because event handlers do not pass values back
    return nil
}

func main() {
    // Find the button element in the DOM
    btn := js.Global().Get("document").Call("getElementById", "btn")
    // Wrap the Go function so JavaScript can invoke it
    fn := js.FuncOf(calculate)
    // Attach the wrapped function to the click event
    btn.Call("addEventListener", "click", fn)
    // Release the JS reference when the page unloads
    js.Global().Get("window").Call("addEventListener", "unload", func() { fn.Release() })
    // Keep the program running indefinitely
    select {}
}

The HTML needs matching IDs for the input, button, and result span. The js.FuncOf wrapper converts a Go function into a JavaScript callable. The browser invokes it whenever the event fires. The function receives the event object as the first argument and any additional parameters in the args slice. Returning nil is standard for event handlers that do not need to pass a value back to JavaScript.

The fn.Release() call is critical. Every js.FuncOf creates a JavaScript function that holds a reference to the Go closure. If you attach it to an element and never remove it, the memory grows. Call .Release() on the js.Func when you are done. The same applies to js.Value objects that wrap large arrays or strings.

The Go community treats syscall/js as a last resort for browser code. It is powerful but verbose. Most developers wrap it in smaller helper functions to keep the main logic clean. The receiver naming convention still applies: keep it short and descriptive. func (h *Handler) ServeWasm(this js.Value, args []js.Value) reads better than func (this *Handler) ServeWasm(...). Also, gofmt handles the indentation. Do not fight it.

Wrap the bridge. Keep the logic pure.

Where things break

The MIME type is the first roadblock. Browsers refuse to load WebAssembly modules unless the server sends Content-Type: application/wasm. If your local server defaults to application/octet-stream, the browser throws a loading error. Configure your server to serve .wasm files with the correct header.

The syscall/js package has strict rules. You cannot pass Go pointers directly to JavaScript. The boundary requires explicit conversion. If you try to pass a raw pointer, the compiler rejects it with cannot use ptr (type *int) as syscall/js.Value in argument. You must wrap values in js.ValueOf or use js.TypedArrayOf for slices.

Standard library packages that rely on OS syscalls will not work. net supports only HTTP over WebAssembly. TCP and UDP sockets are unavailable. os/exec cannot spawn child processes. os file operations are limited to the virtual file system provided by the runtime. If you try to import a restricted package, the compiler stops with import of "os/exec" not allowed in js/wasm build. Stick to pure computation, JSON parsing, or syscall/js for browser interaction.

Memory leaks are common when you forget to release JavaScript references. Every js.FuncOf creates a JavaScript function that holds a reference to the Go closure. If you attach it to an element and never remove it, the memory grows. Call .Release() on the js.Func when you are done. The same applies to js.Value objects that wrap large arrays or strings.

Debugging requires Chrome DevTools. Open the Sources panel and look for the wasm section. You can set breakpoints in the compiled code. The stack traces map back to your Go source files if you build with -gcflags=all=-l to disable inlining, or use go tool objdump to inspect the binary. Performance profiling works through go tool pprof if you enable the built-in profiler endpoint in your code.

Check the MIME type first. Release every JS reference.

When to choose WebAssembly

Use native Go when you need full system access, high-throughput networking, or long-running background services. Use WebAssembly when you want to run existing Go logic in the browser without rewriting it in JavaScript. Use plain JavaScript when the task is simple DOM manipulation or event handling. Use a hybrid approach when you need Go for heavy computation and JavaScript for UI glue. Use a backend API when the data is sensitive or the computation requires server-side resources.

WebAssembly is a bridge, not a replacement. Keep the heavy lifting in Go and let JavaScript handle the interface.

Where to go next