The fork in the road
You built a sorting algorithm in Go. It's fast. You want to run it in a browser demo so users can visualize the data. You compile to WebAssembly. The download is 2.4 megabytes. The page hangs while the garbage collector initializes, and your user's connection drops. You switch to TinyGo. The binary is 40 kilobytes. It loads instantly. But then you try to use encoding/json and the compiler screams that the package doesn't exist.
This is the choice between the full Go ecosystem and the stripped-down TinyGo engine. Both produce WebAssembly, but they serve different masters. One prioritizes compatibility and features. The other prioritizes size and constraints.
Two compilers, one syntax
WebAssembly lets you run compiled code in the browser or other environments. Go has two paths to get there. The official Go compiler supports WebAssembly as a first-class target. It generates code that runs with the Go runtime, including the garbage collector and the full standard library. You get everything you know, but you pay for it in binary size and startup time.
TinyGo is a separate compiler project. It uses the same Go syntax but targets constrained environments. It removes the garbage collector in many cases, cuts the standard library down to essentials, and produces much smaller binaries. The trade-off is clear. You get size and speed with TinyGo, but you lose library breadth and some runtime features.
Think of Go Wasm like a commercial airliner. It carries passengers, cargo, a kitchen, and a full crew. It goes anywhere and handles turbulence. TinyGo is like a precision drone. It carries a single sensor, flies fast, and lands on a fingernail. You wouldn't fly a drone across the ocean, and you wouldn't land a 747 on a microcontroller.
Minimal build and run
The build commands look similar but invoke different toolchains. Go Wasm uses environment variables to set the target. TinyGo uses a flag.
package main
import "fmt"
// Main prints a message to the console.
// This works in both Go Wasm and TinyGo Wasm.
func main() {
fmt.Println("Wasm loaded")
}
# Go Wasm targets the JavaScript environment.
# GOOS=js tells the compiler to generate JS-compatible WASM.
# GOARCH=wasm selects the WebAssembly architecture.
GOOS=js GOARCH=wasm go build -o main.wasm main.go
# TinyGo uses a specific target flag.
# The wasm target produces a WASM file for the browser.
tinygo build -o main.wasm -target wasm main.go
The Go build produces a .wasm file and expects you to use a JavaScript helper. The TinyGo build produces a .wasm file that often needs less glue code. Check the file sizes. The Go binary includes the runtime. The TinyGo binary includes only what you used.
What happens at runtime
When you run GOOS=js GOARCH=wasm go build, the compiler emits a .wasm file and relies on wasm_exec.js. That helper script is in $GOROOT/misc/wasm/. You must serve it or embed it in your page. The script allocates memory, sets up the event loop, and drives the garbage collector.
The Go runtime starts up, initializes the scheduler, and then runs your main. The garbage collector needs a timer tick from JavaScript. If the host page doesn't provide the timer, the GC stops, and memory leaks. The scheduler runs goroutines preemptively. You get the full concurrency model.
TinyGo skips the heavy scheduler. It compiles closer to the metal. The resulting WASM often has no garbage collector overhead. You get faster startup and less memory pressure. The JS glue is minimal or non-existent depending on the target. You interact with JavaScript more directly.
Convention aside: wasm_exec.js is not optional for Go Wasm. If you forget to include the script, the WASM module loads but does nothing. The browser console shows undefined: wasm_exec or similar initialization errors. Always check your HTML includes the helper.
The JavaScript bridge
Both compilers use syscall/js to talk to the host. The API is similar, but the cost of crossing the boundary matters. Every call marshals data between Go and JavaScript. It's slow. Batch calls when you can.
package main
import "syscall/js"
// Calculate computes the square of a number.
// It demonstrates the cost of crossing the JS boundary.
func Calculate(this js.Value, args []js.Value) interface{} {
// args[0] is the number from JavaScript.
// Convert it to a float64 for computation.
val := args[0].Float()
return val * val
}
// Main exports the function to the global JS scope.
// The channel keeps the program alive.
func main() {
// Block forever so the WASM instance doesn't exit.
c := make(chan struct{}, 0)
js.Global().Set("calculate", js.FuncOf(Calculate))
<-c
}
The js.Value objects hold references to JavaScript values. If you don't release them, the JS heap grows. The pattern is to call v.Release() when you are done.
Convention aside: Always release js.Value objects when you are done with them to prevent memory leaks in the host environment. The Go garbage collector cannot see JavaScript memory. You must manage the bridge manually.
TinyGo has stricter rules about types. The interface{} return type in Calculate works in Go Wasm. TinyGo often restricts dynamic interfaces. The compiler may reject code that relies on reflection or type erasure.
Pitfalls and compiler errors
Go Wasm brings the runtime, which means you get the runtime's quirks. The garbage collector can pause the main thread. In a browser, this causes frame drops. If your WASM does heavy allocation, the UI stutters. The scheduler also consumes memory. A simple program might use several megabytes just for the runtime.
TinyGo brings constraints. The standard library is partial. Packages like net/http, crypto/tls, and os are often missing or limited. The compiler rejects imports it cannot support.
The compiler complains with undefined: net/http if you try to import the full HTTP package in TinyGo. TinyGo also rejects features it cannot compile efficiently. You might see tinygo: unsupported feature: reflect if you use reflection heavily. Or tinygo: unsupported feature: interface type if you try to return a dynamic interface on some targets.
Go Wasm has its own errors. If you forget GOARCH=wasm, the compiler builds for your host machine. You get a binary that won't run in the browser. The error is exec format error when you try to load it. If you use GOOS=js without GOARCH=wasm, the compiler rejects the combination with unknown GOOS/GOARCH pair.
Convention aside: GOOS=js GOARCH=wasm is the magic incantation. Don't forget GOARCH=wasm. Also, the receiver naming convention applies here too. Use (b *Buffer) not (this *Buffer). The compiler doesn't care, but the community expects idiomatic names.
Decision matrix
Use Go Wasm when you need the full standard library, including net/http, crypto/tls, and complex reflection. Use Go Wasm when binary size doesn't matter and you are running in a modern browser with plenty of memory. Use Go Wasm when you rely on preemptive goroutines and heavy concurrency patterns. Use Go Wasm when you want to reuse existing Go packages without modification.
Use TinyGo Wasm when you need a binary under 100KB for fast loading in low-bandwidth environments. Use TinyGo Wasm when you are targeting embedded devices like ESP32 or microcontrollers alongside the browser. Use TinyGo Wasm when you want deterministic memory usage without a garbage collector pause. Use TinyGo Wasm when you are building a library for JavaScript developers who expect a lightweight dependency. Use TinyGo Wasm when you need to run on platforms that lack a full JavaScript engine.
TinyGo is not Go. It's a dialect optimized for constraints. Check the binary size before you commit. The garbage collector is a feature in Go Wasm and a bug in TinyGo Wasm, depending on your needs.