The Sandbox Wall
You write a Go service that reads a configuration file, spawns a background worker, and serves JSON over HTTP. It runs perfectly on Linux. You switch the build target to GOOS=js and GOARCH=wasm. You drop the output into an HTML page and refresh the browser. Nothing happens. The console is silent. The file read panics. The HTTP server fails to bind. The worker never starts. You are not debugging a logic error. You are running into the architecture of WebAssembly.
Go assumes it owns the machine. It talks directly to the kernel for files, sockets, and threads. WebAssembly runs inside a host environment, usually a browser. The host controls everything. Go cannot just open("config.json"). It cannot create a thread. It cannot open a socket. The browser gives Go a sandbox. The sandbox has no disk, no network, and no concurrency primitives. To make Go work, you have to rewrite how it talks to the world. The bridge is JavaScript. Every interaction with the outside world goes through syscall/js. You trade Go's direct system calls for JavaScript function calls. The performance cost is negligible for computation. The cost is high for I/O. You also lose Go's threading model. The browser's main thread is single-threaded. Go's goroutines multiplex onto that single thread. If a goroutine blocks, the UI freezes.
WASM is a sandbox. JavaScript is the warden.
How the Bridge Works
The syscall/js package is the only way out of the WASM sandbox. It exposes a js.Value type that wraps a JavaScript value. You can call methods, get properties, and invoke functions. The bridge handles marshaling. A Go string becomes a JS string. A Go int becomes a JS number. A Go slice becomes a JS array. The conversion is automatic for primitive types. Complex types require manual mapping.
When you compile Go to WASM, the toolchain generates a .wasm binary. This binary contains the compiled Go code and the Go runtime. The runtime needs a host to execute. In the browser, the host is provided by wasm_exec.js. This script sets up the Go runtime, links it to the JavaScript environment, and starts the main function. The script also hooks Go's timers and I/O to JavaScript equivalents. time.Sleep works by calling setTimeout. time.Now reads performance.now. The bridge makes Go feel like it has a real runtime, even though it is borrowing JavaScript's event loop.
The bridge is syscall/js. Everything else is a lie.
Minimal Example
Here is the simplest Go WASM program. It logs a message to the browser console and blocks forever to keep the module alive.
package main
import (
// syscall/js provides the bridge to JavaScript.
"syscall/js"
)
// main runs when the WASM module loads.
func main() {
// js.Global() retrieves the browser's global object (window).
// Get("console") accesses the console property.
// Call("log") invokes console.log with the argument.
// The bridge marshals the Go string to a JS string.
js.Global().Get("console").Call("log", "Hello from Go WASM")
// Block forever so the WASM instance stays alive.
// If main returns, the program exits and the module unloads.
// The browser reclaims the memory immediately.
select {}
}
The select {} statement is mandatory. Go programs exit when main returns. In a browser, you usually want the WASM instance to persist so it can respond to events. Blocking keeps the goroutine alive. The WASM instance remains loaded. The memory stays allocated. You can remove the block if you only need to run initialization code and then unload, but most applications need to stay resident.
Goroutines are cheap. Keeping the main goroutine alive is free.
Runtime Mechanics
When the browser loads the page, it executes wasm_exec.js. This script fetches the .wasm file and instantiates the module. Instantiation links the Go runtime to the JavaScript environment. The runtime initializes the heap, sets up the garbage collector, and starts the main function. The Go garbage collector runs concurrently with JavaScript. It uses a write barrier to track allocations. The GC pauses are usually short, but they can jank the UI if the heap is large.
The build flags matter. Go WASM binaries are large by default. The standard library includes code for networking, OS calls, and threading that are useless in the browser. Use -ldflags="-s -w" to strip symbol tables and debug info. This reduces the binary size significantly. You can also use GOEXPERIMENT=wasip1 for newer WASI support, but browser support for WASI is still evolving. Stick to GOOS=js for maximum compatibility.
The compiler rejects unused imports with imported and not used. This rule applies in WASM too. Keep the import list clean. Every imported package adds to the binary size. Size matters in the browser. A large WASM file increases load time. Users abandon slow pages.
Trust gofmt. Argue logic, not formatting.
Realistic Interaction
Real applications need to respond to user input. You register Go functions with the JavaScript global scope so HTML event handlers can call them. The js.FuncOf helper wraps a Go function and makes it callable from JS.
package main
import (
"syscall/js"
)
// processClick handles the button event from JavaScript.
// The first argument is always the function itself, per JS convention.
// The second argument is the Event object passed by the browser.
func processClick(this js.Value, args []js.Value) interface{} {
// args[0] is the click event.
// We ignore the event details and just update the DOM.
_ = args[0]
// Get the element with id "result" from the document.
// Call("getElementById") invokes the DOM method.
el := js.Global().Get("document").Call("getElementById", "result")
// Set the textContent property to a string from Go.
// The bridge marshals the Go string to a JS string.
el.Set("textContent", "Processed by Go!")
// Return nil because this is a void function in JS terms.
// Returning a value maps to the JS return value.
return nil
}
func main() {
// Register the Go function with the global JS scope.
// Now JS can call window.processClick().
// js.FuncOf creates a wrapper that handles the bridge.
js.Global().Set("processClick", js.FuncOf(processClick))
// Keep the program running to listen for events.
select {}
}
The processClick function signature is fixed. The first parameter is this, which represents the JavaScript object the function is bound to. The second parameter is a slice of arguments. The return type is interface{}. The bridge marshals the return value back to JS. Returning nil maps to undefined in JS. Returning a string maps to a JS string.
There is a memory leak trap here. js.FuncOf returns a js.Func value. If you store this value in a variable, you must call Release() on it when you are done. The JavaScript engine holds a reference to the Go closure. The Go garbage collector cannot reclaim the memory while JS holds the reference. If you pass js.FuncOf directly to Set, the wrapper is managed by the global scope. You still need to be careful. If you replace the function later, the old wrapper leaks. Always track js.Func lifecycles.
The worst goroutine bug is the one that never logs.
Pitfalls and Leaks
Go WASM has sharp edges. The compiler cannot catch JavaScript errors. Runtime panics happen when the bridge breaks. Accessing a property that does not exist panics with wasm: undefined: <property>. Calling a function with the wrong number of arguments panics with wasm: wrong number of arguments. The error messages are clear, but they crash the WASM instance. The browser tab may freeze or show a blank page.
File system access fails. os.Open returns an error like open config.json: no such file or directory. The virtual file system is empty. You must use syscall/js to read File objects from JavaScript. The user selects a file via an input element. JavaScript passes the File object to Go. Go reads the file using js.Value methods. You cannot use ioutil or os for file I/O.
Threading is limited. runtime.GOMAXPROCS is ignored in the browser. Go runs on a single thread. Goroutines multiplex onto the JavaScript event loop. If a goroutine blocks on a channel and no one sends, the event loop stalls. The UI freezes. The browser shows a "Page Unresponsive" warning. Always provide a cancellation path for goroutines. Use context.Context to signal shutdown. Respect deadlines.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.
Convention asides matter. Error handling is verbose by design. if err != nil { return err } makes the unhappy path visible. In WASM, errors often mean the bridge broke. Log errors to console.error so you can debug them. The browser console is your friend.
Context is plumbing. Run it through every long-lived call site.
Decision Matrix
WebAssembly is not a replacement for Go on the server. It is a tool for running Go in constrained environments. Choose the right tool based on your requirements.
Use syscall/js when you need to interact with the browser DOM, APIs, or user input. The bridge is the only way to access browser features.
Use wazero when you want to run Go WASM on a server with controlled file system access and no browser dependencies. wazero is a pure Go runtime that allows injecting custom APIs and virtual file systems. It is fast and safe.
Use wasmtime when you need native performance, threading, and direct OS access outside the browser sandbox. wasmtime is a Rust-based runtime that supports WASI and threads. It runs Go WASM with near-native speed.
Use plain JavaScript when the task is simple DOM manipulation. Compiling Go to WASM adds startup latency and binary size. The overhead outweighs the benefits for trivial logic.
Use a Web Worker when you need to offload heavy Go computation without blocking the main UI thread. Spawn a worker, load WASM there, and communicate via messages. The worker runs on a separate thread. The UI stays responsive.
WASM is for computation, not I/O. Let the host handle the world.