Go on WebAssembly: Browser, WASI, and Embedded Paths
Go compiles to WebAssembly, but WebAssembly is not one deployment model.
The same language reaches the browser DOM, server-side WASI hosts, and flash-constrained boards through different GOOS/GOARCH pairs and sometimes a different compiler (TinyGo).
Choosing the path early determines binary size, stdlib coverage, and which host runtime you embed.
Summary
- Go on WASM splits into three lanes - browser (
js/wasm), WASI server (wasip1/wasm), and embedded/constrained (TinyGo) - each with different syscalls, hosts, and size budgets. - Insight: The wrong lane ships a 10+ MiB browser bundle when you needed a 200 KiB edge filter, or pulls in
syscall/jswhere a WASI module should run headless. - Key Concepts:
syscall/js, WASI preview 1 (wasip1), host runtime, import surface, TinyGo-target, linear memory. - When to Use: Evaluating Go for client-side compute, portable CLI tools as
.wasm, FaaS/edge workers, or firmware-adjacent WASM on devices. - Limitations/Trade-offs: Full Go WASM binaries are large; browser DOM access requires JS bridging; TinyGo omits parts of the stdlib and reflect; not every
net/httppattern ports unchanged. - Related Topics: WASI host runtimes, size optimization, Go 1.26 heap changes, syscall/js interop, TinyGo board targets.
Foundations
WebAssembly is a portable bytecode format with a linear memory model and an import/export ABI.
Go does not emit a standalone OS - it emits a .wasm module that imports host functions for I/O (files, clocks, randomness, DOM, sockets).
The Go compiler target (GOOS + GOARCH, or TinyGo -target) decides which imports the linker expects and which stdlib packages compile.
Browser path (GOOS=js GOARCH=wasm): Go runs inside a JavaScript host.
The historical model uses syscall/js to call DOM APIs and register callbacks.
The host loads wasm_exec.js (from GOROOT/misc/wasm) to implement the Go runtime's JS imports.
This path is for interactive UIs and WASM-in-the-tab demos, not for headless servers.
WASI path (GOOS=wasip1 GOARCH=wasm): Go compiles to a headless module that speaks WASI Preview 1 - a POSIX-ish capability surface (files, environment, clocks, random).
You run the .wasm with a host runtime such as wasmtime, wazero, or Spin.
This is the default mental model for "Go binary, but portable WASM" on servers, CI sandboxes, and edge platforms.
Embedded / constrained path (TinyGo): TinyGo is a separate compiler that targets microcontrollers (ARM Cortex-M, AVR, RISC-V) and also wasm with much smaller binaries.
It trades stdlib completeness and compile-time features for flash/RAM budgets.
Use it when the device or WASM download size is the hard constraint.
Your Go source
|
+-- go build (gc) -- GOOS=js GOARCH=wasm --> browser + syscall/js
|
+-- go build (gc) -- GOOS=wasip1 GOARCH=wasm --> WASI .wasm + host runtime
|
+-- tinygo build -- -target=... --> MCU firmware or tiny WASM
Mechanics & Interactions
Import surface is the contract between module and host.
Browser modules import JS runtime shims; WASI modules import wasi_snapshot_preview1 functions; TinyGo WASM may import a minimal set tuned for size.
If you compile for wasip1 but load the module in a browser without WASI polyfills, instantiation fails at import resolution.
Memory and runtime: Full Go WASM includes the Go scheduler and GC in the module.
Linear memory grows as the heap grows.
Go 1.26 allocates heap in smaller chunks for sub-16 MiB heaps, which materially reduces idle memory for WASI microservices and small filters.
TinyGo uses a different runtime with lower baseline memory, at the cost of goroutine/reflection limits.
Build tags and file splits: Browser code often lives behind //go:build js && wasm so server packages do not pull syscall/js into WASI builds.
WASI code uses //go:build wasip1 (or the default non-JS build) and should avoid DOM-specific packages entirely.
Host runtimes differ in embedding ergonomics:
- wasmtime (CLI and libraries) - mature WASI, common in local dev.
- wazero - pure Go runtime, ideal when your host is already a Go service and you want no cgo.
- Spin (Fermyon) - opinionated HTTP trigger model over WASM components at the edge.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
js/wasm + syscall/js | DOM access from Go | Large binary, JS glue required | Browser demos, WASM UI widgets |
wasip1/wasm (gc toolchain) | Full stdlib, familiar go build | Multi-MiB modules typical | Server plugins, portable CLIs, wazero embedding |
| TinyGo WASM / MCU | Very small binaries | Incomplete stdlib, fewer libs | Embedded boards, size-capped edge WASM |
Advanced Considerations & Applications
Polyglot WASM pipelines: Teams often author hot paths in Rust for minimum size and orchestration in Go via wazero.
Go 1.26's heap improvements narrow the gap for small services that benefit from staying in one language.
Security and sandboxing: WASI capability-based I/O fits multi-tenant hosts better than giving modules raw host filesystem access.
Pre-open directories and environment variables explicitly in wasmtime/wazero config rather than assuming POSIX parity.
CI strategy: Build matrices should include GOOS=wasip1 GOARCH=wasm (and TinyGo if used) alongside linux/amd64.
Run modules with wasmtime run or wazero in integration tests to catch missing imports early.
Deprecation awareness: Browser-first Go WASM relying on syscall/js is a specialized niche.
Greenfield server work should default to wasip1 unless DOM bridging is the actual requirement.
Common Misconceptions
-
"One .wasm runs everywhere" - Import sets differ per target; a browser module is not interchangeable with a WASI module without recompilation and a matching host.
-
"TinyGo is just
-ldflags -s -wfor Go" - TinyGo uses a different compiler and runtime; language support and stdlib coverage diverge materially. -
"WASI equals full Linux" - WASI preview 1 covers files, clocks, and randomness, not a complete network stack on every host.
Check your runtime's socket and HTTP extensions before porting net/http servers verbatim.
- "syscall/js is fine for server WASM" - Pulling JS interop into modules meant for wasmtime/wazero bloats binaries and breaks headless hosts.
Keep browser builds isolated behind build tags.
FAQs
What is the default server-side Go WASM target today?
Use GOOS=wasip1 and GOARCH=wasm with the standard Go toolchain.
This produces a WASI Preview 1 module you run with wasmtime, wazero, or compatible edge hosts.
When do I still need GOOS=js GOARCH=wasm?
When Go must call browser APIs (DOM, fetch, canvas) via syscall/js.
For compute-only workloads in the browser, consider compiling to WASI and using a host with WASI-in-JS, or keep the hot path in JavaScript/TypeScript.
Can I use net/http on WASI?
Networking depends on the host runtime and WASI version.
Many hosts expose sockets through extensions; Spin maps HTTP triggers directly.
Prototype on your target host before assuming ListenAndServe works unchanged.
How does TinyGo relate to the official Go compiler?
TinyGo is an independent LLVM-based toolchain focused on small binaries.
It shares Go syntax but does not guarantee every stdlib package or reflection pattern works.
Why are Go WASM files so large compared to Rust?
Full Go modules include runtime, scheduler, and GC.
TinyGo and import minimization shrink size; Go 1.26 heap chunking reduces memory overhead but not necessarily initial download size without -ldflags=-s -w.
What host should a Go shop pick first?
wazero when the embedder is already Go (no cgo, easy CI).
wasmtime for CLI/dev parity and broad WASI coverage.
Spin when deploying HTTP handlers to Fermyon Cloud or compatible edge runtimes.
Do I need wasm_exec.js for wasip1 modules?
No. wasm_exec.js is for the js/wasm target.
WASI modules run under a WASM runtime that provides WASI imports, not the browser JS shim.
How do build tags fit into a multi-target repo?
Place browser-only code in files tagged js && wasm, shared logic in default builds, and keep main packages separate per target if imports diverge.
Did Go 1.26 change WASM materially?
Yes for memory: smaller heap increments help sub-16 MiB workloads.
The compiler also always uses certain WASM instruction extensions (signext, satconv settings are ignored).
Which path is best for microcontrollers?
TinyGo with an explicit -target for your board (for example arduino, pico, cortex-m variants).
Full go build does not emit firmware images for those devices.
Related
- WebAssembly & TinyGo Basics - hands-on wasip1 and TinyGo hello builds
- Compiling with GOOS=wasip1 GOARCH=wasm - toolchain flags and run commands
- syscall/js: Go in the Browser - DOM callbacks and build tag splits
- WASI Host Runtimes: wasmtime, wazero & Spin - embedding and CLI execution
- Go 1.26 WASM Runtime & Memory Improvements - heap chunk changes and viability
- Native Go vs TinyGo vs Rust WASM Decision Guide - ranked choices per scenario
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).