The megabyte problem in the browser
You wrote a fast algorithm in Go. You want to run it in the browser to offload work from the server. You compile it to WebAssembly. The result is a 2.5 megabyte file. The browser downloads it. The user stares at a loading spinner. The user leaves.
The problem isn't your code. The problem is the compiler. Standard Go is built for servers where megabytes are free and CPU cycles are precious. The standard compiler includes a heavy runtime, assembly optimizations for math and strings, and a full garbage collector. WebAssembly has different constraints. Every kilobyte adds to download time. Every byte of memory increases the initial heap allocation. TinyGo exists to solve this mismatch.
TinyGo is a separate compiler for the Go language. It uses LLVM under the hood instead of Go's custom compiler. It strips out runtime features you don't need in constrained environments. It targets microcontrollers and WebAssembly where size matters more than raw throughput. The purego build tag is the primary tool for shrinking WebAssembly binaries. It forces the compiler to use pure Go implementations instead of assembly routines, removing code that WebAssembly cannot execute anyway.
TinyGo and the LLVM backend
Standard Go uses a compiler written in Go that targets a custom bytecode-like intermediate representation before generating machine code. TinyGo uses LLVM, the same infrastructure that powers Clang and Swift. LLVM brings aggressive optimization passes that are particularly effective for small targets. It can inline functions more aggressively, eliminate dead code more thoroughly, and reduce register pressure.
The LLVM backend also allows TinyGo to target architectures that standard Go doesn't support, like RISC-V or specific microcontroller cores. For WebAssembly, LLVM generates compact instructions and handles the linear memory model efficiently.
TinyGo also changes the garbage collector. Standard Go uses a concurrent, tri-color mark-and-sweep collector. It's fast and keeps pause times low, but the implementation is large. TinyGo defaults to a "leaking" garbage collector for WebAssembly. The leaking collector allocates memory but never frees it. The binary size drops dramatically because the complex marking logic disappears. The trade-off is that memory usage grows over time. This works perfectly for short-lived computations. It fails for long-running applications. You can enable a conservative garbage collector in TinyGo, but the binary grows back up.
Convention aside: gofmt works on TinyGo code exactly the same way. TinyGo is Go. The syntax, formatting, and style rules are identical. Run gofmt on your TinyGo code. The toolchain expects standard formatting.
The purego tag explained
The Go standard library contains assembly implementations for performance-critical operations. Packages like math, crypto, and strings often have .s files with hand-tuned assembly for x86 and ARM. These routines make operations faster on native hardware.
WebAssembly does not support x86 or ARM assembly. When you compile for WebAssembly, those assembly files are useless. The standard compiler might still include them in the binary if the linker doesn't strip them, or it might fail to compile them. TinyGo handles this better, but the purego build tag gives you explicit control.
The purego tag tells the build system to ignore assembly files. It forces the compiler to use the Go implementation of every function. This has two effects. First, it removes the assembly blobs that would otherwise bloat the binary. Second, it gives the LLVM optimizer a uniform view of the code. When everything is Go, the optimizer can inline and simplify across package boundaries more effectively.
The downside is performance. Pure Go implementations of square roots or cryptographic hashes are slower than assembly. You are trading CPU cycles for binary size. In the browser, download size often matters more than computation speed because the user waits for the binary before any computation happens.
Minimal example
Here's the simplest TinyGo WebAssembly module. It exports a single function to JavaScript.
package main
//export Add
func Add(a, b int) int {
// The purego tag ensures no assembly is used for arithmetic.
return a + b
}
func main() {
// main must exist for the binary to compile.
}
The //export comment is a directive for TinyGo. It marks the function as visible to the host environment. JavaScript can call Add directly. The main function is required even though you never call it. The compiler needs an entry point to anchor the binary generation.
Compile this with the purego tag to strip assembly:
# Build for Wasm with pure Go to strip assembly.
tinygo build -o add.wasm -target wasm -tags purego main.go
The -target wasm flag selects the WebAssembly configuration. The -tags purego flag activates the build tag. The output add.wasm will be significantly smaller than a standard Go build.
Walkthrough of the build
When you run that command, TinyGo parses your code and the standard library. It resolves dependencies and checks for compatibility. The -tags purego flag is processed during dependency resolution. Any file with a build constraint like //go:build !purego is excluded. Any assembly file is skipped. The compiler falls back to the Go source files.
TinyGo then feeds the Go code into LLVM. LLVM generates WebAssembly instructions. The optimizer runs multiple passes. It inlines Add if it's called internally. It removes unused functions. It strips the garbage collector if the leaking GC is enabled and no heap allocation occurs.
The linker produces the final .wasm file. The file contains the code section, the memory section, and the export table. The export table lists Add so JavaScript can find it. The memory section defines the linear memory buffer. TinyGo manages this buffer for the Go heap.
Convention aside: The receiver name in TinyGo methods follows the same convention as standard Go. Use one or two letters matching the type. (b *Buffer) Write(...) is correct. (this *Buffer) is not. TinyGo doesn't change naming conventions.
Realistic example
Real-world WebAssembly modules usually process data. Here's a string processing function that simulates a common workload.
package main
import "strings"
//export Process
func Process(data string) string {
// strings package falls back to Go code with purego tag.
trimmed := strings.TrimSpace(data)
return strings.ToUpper(trimmed)
}
func main() {
// Anchor for the compiler.
}
The strings package contains assembly optimizations for TrimSpace and ToUpper. Without purego, the compiler might try to include those routines. With purego, it uses the Go implementations. The Go implementations are slower, but they compile to compact WebAssembly. The binary stays small.
JavaScript calls Process with a string. TinyGo handles the string conversion between JavaScript and WebAssembly memory. It allocates space in linear memory, copies the string, and returns a pointer and length. The JavaScript glue code reads the result.
Convention aside: context.Context is rarely useful in TinyGo WebAssembly modules. WebAssembly runs in a single thread. There's no background processing. You don't need cancellation tokens. Skip context unless you are wrapping a library that requires it.
Garbage collection and memory
TinyGo's default garbage collector for WebAssembly is the leaking collector. It allocates memory but never frees it. This is safe for short-lived computations. If your module processes a request and exits, the memory is reclaimed when the module is destroyed.
If your module runs for a long time, the leaking collector causes memory growth. The linear memory buffer expands. The browser may kill the tab if memory usage gets too high. You can enable a conservative garbage collector to fix this:
# Enable conservative GC for long-running modules.
tinygo build -o long.wasm -target wasm -tags purego -gc conservative main.go
The conservative GC scans memory to find live objects. It frees unused memory. The binary size increases because the GC code is included. The runtime overhead increases because the GC pauses execution to scan memory. Use the conservative GC only when you need long-lived state.
Convention aside: Don't pass *string to exported functions. Strings are already cheap to pass by value. TinyGo handles string interop efficiently. Pointers add complexity without benefit.
Pitfalls and compiler errors
TinyGo is a subset of Go. It doesn't support everything. If you try to use a package that TinyGo doesn't support, the compiler rejects the build with package net/http is not supported by this target. Check the compatibility matrix before you start.
Reflection is another limitation. Heavy use of reflection bloats the binary because the compiler must include type information for every reflected type. If you use reflection excessively, you might hit reflection is not supported or see the binary size explode. Prefer interfaces and type switches when possible.
The purego tag can break code that relies on assembly for correctness. Some packages use assembly for atomic operations. If you force purego on a package that needs atomics, you might get a runtime panic or incorrect behavior. The compiler usually catches this with missing atomic implementation or similar errors.
Interface usage can also increase binary size. Every method on an interface adds code to the binary. If you pass many different types through an interface, the compiler includes code for all of them. Keep interfaces small and focused.
Convention aside: _ discards values intentionally. result, _ := ... says you considered the second return value and chose to drop it. Use it sparingly with errors. Dropping errors in TinyGo code is just as dangerous as in standard Go. The binary is small, but the bug is real.
Decision matrix
Use TinyGo when you are targeting WebAssembly and binary size is the bottleneck. Use TinyGo when you are deploying to microcontrollers where RAM is measured in kilobytes. Use TinyGo when you need to target architectures that standard Go doesn't support.
Use standard Go when you need maximum CPU performance and the binary size doesn't matter. Use standard Go when you rely on advanced reflection or packages that TinyGo has dropped. Use standard Go when you are building a server application where download size is irrelevant.
Use the purego tag when you are compiling for WebAssembly or RISC-V and want to strip assembly blobs. Use the purego tag when you want to ensure portability across architectures by avoiding assembly dependencies. Use standard build tags when you are targeting x86 or ARM and want the assembly speed boost.
TinyGo trades cycles for bytes. Pick the trade that matches your constraint. The purego tag is your scalpel. Use it to remove the assembly fat. Check the compatibility matrix before you start. TinyGo is a subset, not a superset.