syscall/js: Go in the Browser
The syscall/js package lets Go WASM talk to JavaScript: DOM nodes, fetch, timers, and callbacks from JS into Go.
It is the distinguishing piece of the GOOS=js GOARCH=wasm target.
Keep it behind build tags so server-side WASI modules never import browser-only syscalls.
Summary
Browser Go WASM compiles with GOOS=js and GOARCH=wasm.
The host page loads wasm_exec.js plus your main.wasm.
Go functions registered with js.FuncOf receive JS events; js.Global() reaches browser globals like document and window.
Because main would otherwise exit, long-lived UIs block with select {}.
Recipe
Quick-reference recipe card - copy-paste ready.
//go:build js && wasm
package main
import (
"syscall/js"
)
func main() {
doc := js.Global().Get("document")
btn := doc.Call("createElement", "button")
btn.Set("textContent", "Click me")
btn.Call("addEventListener", "click", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
js.Global().Get("console").Call("log", "clicked from Go")
return nil
}))
doc.Get("body").Call("appendChild", btn)
select {}
}When to reach for this:
- WASM-powered UI widgets embedded in existing React/Vue apps
- Teaching demos that must manipulate the DOM from Go
- Gradual migration prototypes where Go owns one canvas or control panel
- Internal tools that already standardize on Go and need a thin browser GUI
Working Example
index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Go WASM Demo</title>
<script src="wasm_exec.js"></script>
</head>
<body>
<div id="out"></div>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
.then((result) => {
go.run(result.instance);
});
</script>
</body>
</html>main.go:
//go:build js && wasm
package main
import (
"strconv"
"syscall/js"
)
func main() {
counter := 0
doc := js.Global().Get("document")
out := doc.Call("getElementById", "out")
btn := doc.Call("createElement", "button")
btn.Set("innerText", "Increment")
handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
counter++
out.Set("innerText", "count="+strconv.Itoa(counter))
return nil
})
btn.Call("addEventListener", "click", handler)
out.Call("appendChild", btn)
select {}
}Build and serve:
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .
GOOS=js GOARCH=wasm go build -o main.wasm .
python3 -m http.server 8080What this demonstrates:
js.FuncOfwraps Go closures as JS-callable functions.- DOM APIs are accessed through
js.Valuemethod calls, not cgo. select {}preventsmainfrom returning and tearing down the runtime.wasm_exec.jssupplies imports the Go WASM module requires.
Deep Dive
How It Works
- The Go compiler emits WASM that imports JavaScript functions defined in
wasm_exec.js. js.Valueis an opaque handle to a JS value;Get,Set, andCallmarshal across the boundary.- Callbacks from JS run on the Go scheduler; keep handlers short to avoid blocking UI threads perceptibly.
- Garbage collection tracks
js.Valuehandles; release long-lived callbacks withjs.Func.Release()when removing listeners.
Build Tag Split
| File tag | Compiled when | Role |
|---|---|---|
//go:build js && wasm | Browser target | DOM, syscall/js entry |
//go:build !js || !wasm | Native + WASI | Shared logic without JS imports |
| default (no tag) | All non-tagged targets | Portable packages |
js.Value Patterns
// Fetch example (host must provide fetch in globals)
promise := js.Global().Call("fetch", "/api/data")
// Then chain .then with js.FuncOf handlers - mirror JS promise styleGo Notes
// Release callbacks registered on long-lived pages to avoid leaks.
var fn js.Func
fn = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
fn.Release()
return nil
})Gotchas
-
Forgetting
select {}-mainreturns, the runtime exits, and callbacks never fire. Fix: block onselect {}or a channel fed by shutdown logic. -
Importing syscall/js in WASI builds - Breaks
GOOS=wasip1compilation or bloats headless modules. Fix: strictjs && wasmfile tags. -
Serving without correct MIME type - Some static servers serve
.wasmasapplication/octet-stream. Fix: useapplication/wasmorinstantiateStreamingfails; configure server MIME map. -
Leaking js.Func callbacks - Re-registering listeners without
Releaseretains Go closures. Fix: callRelease()when removing DOM nodes or navigating away. -
Expecting full stdlib networking - Browser security shapes
net/http; many patterns needfetchvia JS. Fix: wrapfetchor keep network code in WASI workers. -
Large bundle sizes - js/wasm Go binaries are multi-megabyte. Fix: measure before committing; consider TinyGo or host compute off-thread.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
wasip1/wasm in browser via WASI polyfill | Compute-heavy, no direct DOM | You need fine-grained DOM control |
| TypeScript/React UI + WASM plugin | Production UX with Go compute core | Team wants pure Go UI code |
TinyGo wasm target | Smaller browser WASM | You need full reflect/generics libs |
| WebAssembly-GC languages (future hosts) | Host supports GC proposal | You must ship today on all browsers |
FAQs
Is syscall/js still supported?
Yes for the js/wasm target, but treat it as a specialized bridge.
Default new work to wasip1 unless DOM access is explicit.
Where does wasm_exec.js come from?
Copy from $(go env GOROOT)/misc/wasm/wasm_exec.js.
Version it with your Go toolchain so imports stay matched.
Can I use multiple .go files?
Yes. Tag every file that imports syscall/js with js && wasm, or place them in a browser-only package.
How do I call Go from JavaScript?
Export functions with js.Global().Set("goDoThing", js.FuncOf(...)) before blocking.
Read values from JS through args []js.Value.
Does the race detector work?
Not on js/wasm builds.
Keep concurrency tests on linux/amd64 packages shared with WASM logic.
Can I use fmt.Println?
Yes - output routes to the browser console via the JS runtime shim.
Prefer structured logging for real apps.
How do I pass byte slices to JS?
Use js.CopyBytesToJS and js.CopyBytesToGo helpers to move data across the boundary without extra copies where possible.
What about workers?
You can run WASM in Web Workers with a custom wasm_exec.js loader, but DOM APIs are not available in workers.
Architect accordingly.
Is TinyGo syscall/js the same?
TinyGo supports a subset of WASM targets; verify its JS interop docs for your version.
API shapes differ from gc toolchain behavior.
Should server modules ever import syscall/js?
No. Headless WASM hosts do not provide JS shims.
Split packages and enforce with lint rules or go vet build tag checks.
Related
- Go on WebAssembly: Browser, WASI, and Embedded Paths - choose browser vs WASI
- Compiling with GOOS=wasip1 GOARCH=wasm - headless compile path
- WebAssembly & TinyGo Basics - build tag skeleton example
- WASM Size Optimization: TinyGo vs gc WASM - browser bundle size
- WebAssembly & TinyGo Best Practices - avoid syscall/js in server modules
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).