DEV Community

Cover image for Why Your Go Binary Is Too Fat for WebAssembly (and How TinyGo Fixes It)
Alan West
Alan West

Posted on

Why Your Go Binary Is Too Fat for WebAssembly (and How TinyGo Fixes It)

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 reflect package 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")
}
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For WASI (server-side runtimes like Wasmtime or Wasmer):

tinygo build -o main.wasm -target=wasip1 ./main.go
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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 {}
}
Enter fullscreen mode Exit fullscreen mode

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 reflect extensively, 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
Enter fullscreen mode Exit fullscreen mode

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/json when possible.
  • Check the TinyGo compatibility page before adding any third-party dependency. One incompatible transitive dependency can block your entire build.
  • Use tinygo build in CI early. Catch compatibility issues before they pile up.
  • Profile your binary size. TinyGo has a -size flag 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)