If you've ever tried compiling a Go program to WebAssembly and watched the output balloon to 10+ MB for a glorified "hello world," you know the pain. I hit this wall last year when I was building a browser-based tool that needed some Go logic running client-side. The standard Go compiler (GOOS=js GOARCH=wasm go build) produced a binary so large that my page load time was genuinely embarrassing.
The fix? TinyGo. But getting there wasn't straightforward, so let me walk you through the problem, why it happens, and how to actually ship lean Go binaries for both WebAssembly and embedded targets.
The Root Cause: Go's Runtime Is Massive
Standard Go wasn't designed for constrained environments. When you compile to WebAssembly with the mainline Go compiler, you're dragging along:
- The full goroutine scheduler
- A complete garbage collector
- The entire
reflectpackage infrastructure - Runtime type information for every type in your program
For a server binary, none of this matters. For a .wasm file that a browser needs to download, parse, and instantiate? It's a dealbreaker.
Here's the reality check. Compile this with standard Go:
package main
import "fmt"
func main() {
fmt.Println("hello from wasm")
}
# Standard Go compiler targeting WebAssembly
GOOS=js GOARCH=wasm go build -o main.wasm main.go
ls -lh main.wasm
# Typically around 7-12 MB depending on Go version
That's not a typo. A single fmt.Println call can produce a multi-megabyte WASM binary. The fmt package alone pulls in reflection, which pulls in type metadata, which pulls in half the runtime.
Enter TinyGo: A Different Compiler, Not Just a Flag
TinyGo isn't a wrapper around the Go compiler. It's an entirely separate compiler built on LLVM that targets small environments — microcontrollers and WebAssembly. It uses a different garbage collector (typically a conservative mark-sweep designed for small heaps), a simpler scheduler, and aggressively dead-code-eliminates anything your program doesn't actually use.
The key difference: standard Go links in the full runtime regardless. TinyGo only includes what you touch.
Step-by-Step: Shipping a Lean WASM Binary
1. Install TinyGo
On macOS with Homebrew:
brew tap tinygo-org/tools
brew install tinygo
# Verify it's working
tinygo version
On Linux, grab the release from the TinyGo GitHub releases page. On Windows, there's an MSI installer. Check the official install docs at tinygo.org/getting-started for your platform — I'm not going to pretend I've tested every distro.
2. Compile to WebAssembly
TinyGo supports two WASM targets: browser (wasm) and WASI (wasip1). The target you pick matters.
For browser usage:
tinygo build -o main.wasm -target=wasm ./main.go
ls -lh main.wasm
# Typically under 500 KB for simple programs, often much smaller
For WASI (server-side runtimes like Wasmtime or Wasmer):
tinygo build -o main.wasm -target=wasip1 ./main.go
That size difference is dramatic. We're talking about going from 8-12 MB down to a few hundred KB for the same functionality. In some minimal cases, I've seen TinyGo produce WASM binaries under 50 KB.
3. Load It in the Browser
TinyGo ships its own JavaScript glue file (different from the one standard Go uses). You'll find it in TinyGo's installation directory:
// You need TinyGo's wasm_exec.js, NOT the one from standard Go
// Copy it from: $(tinygo env TINYGOROOT)/targets/wasm_exec.js
const go = new Go(); // Go class is defined in wasm_exec.js
WebAssembly.instantiateStreaming(
fetch("main.wasm"),
go.importObject
).then((result) => {
go.run(result.instance);
});
A mistake I see constantly: people use the wasm_exec.js from their standard Go installation with a TinyGo-compiled binary. They're not compatible. You'll get cryptic import errors at instantiation time. Use TinyGo's version.
4. Expose Functions to JavaScript
Here's where it gets practical. You probably want Go functions callable from JS:
package main
import "syscall/js"
// fibonacci calculates the nth fibonacci number
func fibonacci(n int) int {
if n <= 1 {
return n
}
a, b := 0, 1
for i := 2; i <= n; i++ {
a, b = b, a+b
}
return b
}
func main() {
// Register the function so JavaScript can call it
js.Global().Set("goFibonacci", js.FuncOf(
func(this js.Value, args []js.Value) interface{} {
n := args[0].Int()
return fibonacci(n)
},
))
// Keep the Go program running so the exported
// functions remain available
select {}
}
Compile that with TinyGo and you'll get a WASM binary that's a fraction of what standard Go would produce, with the same functionality.
The Tradeoffs (Because There Are Always Tradeoffs)
TinyGo isn't a drop-in replacement for Go. You will hit limitations:
-
Incomplete standard library support. Packages like
net/http,encoding/json, and others that rely heavily on reflection have partial or no support. TinyGo maintains a compatibility table that you should check before committing. -
Reflection is limited. If your code (or a dependency) relies on
reflectextensively, expect breakage. This is the single biggest source of frustration. - Goroutines work but differently. TinyGo uses a cooperative scheduler by default, not Go's preemptive one. Most code works fine, but edge cases around heavy concurrency can behave differently.
- CGo support is limited. Don't expect to wrap arbitrary C libraries the way you would with standard Go.
Honestly, for WASM and embedded work, these tradeoffs are usually worth it. You're not building a web server — you're building something that needs to be small and fast.
Bonus: The Same Compiler Works for Microcontrollers
This is the part that sold me on TinyGo as a tool worth learning. The same compiler that produces lean WASM also targets microcontrollers directly. Arduino boards, the BBC micro:bit, ESP32, RP2040 (Raspberry Pi Pico) — TinyGo supports a long list of boards.
# Flash Go code directly to an Arduino Nano
tinygo flash -target=arduino-nano ./blink.go
# Target a Raspberry Pi Pico
tinygo flash -target=pico ./main.go
Writing firmware in Go instead of C is genuinely pleasant. You get type safety, clean syntax, and goroutines for concurrent hardware tasks — all without manually managing memory.
Prevention: How to Avoid the Bloat Problem From Day One
If you're starting a project that targets WASM or embedded:
- Start with TinyGo from the beginning. Don't write against standard Go and port later — you'll accumulate incompatible dependencies.
-
Avoid reflection-heavy packages. Use code generation or manual serialization instead of
encoding/jsonwhen possible. - Check the TinyGo compatibility page before adding any third-party dependency. One incompatible transitive dependency can block your entire build.
-
Use
tinygo buildin CI early. Catch compatibility issues before they pile up. -
Profile your binary size. TinyGo has a
-sizeflag that shows you what's eating up space:tinygo build -size=short -target=wasm ./main.go
The Bottom Line
Standard Go's WASM output is too large for most client-side use cases. That's not a hot take — it's arithmetic. TinyGo solves this by being a fundamentally different compiler that only includes what your code actually needs.
The learning curve is minimal if you already know Go. The friction comes from library compatibility, not from the language itself. My advice: start your next WASM project with TinyGo, hit the compatibility wall early, and plan around it. That's way less painful than discovering your shipping binary is 12 MB on launch day.
Top comments (0)