Compiling Go to Wasm with TinyGo
This guide answers one task: compile Go to a WebAssembly module small enough to ship to a browser, using TinyGo — and know which parts of Go you are giving up in exchange.
Prerequisites
- [ ] TinyGo 0.33 or later, and a matching Go toolchain.
- [ ]
wasm_exec.jsfrom the TinyGo distribution, not from the Go one. - [ ] A task suited to a module: computation over data, not something calling browser APIs constantly.
- [ ] A local server; the module will not load from
file://.
Why not the standard toolchain
GOOS=js GOARCH=wasm go build works and produces a module between roughly 800 kB and 2.5 MB compressed
for a trivial program. The reason is that Go’s runtime — its garbage collector, goroutine scheduler,
reflection system and type metadata — is compiled in, and very little of it can be removed because Go’s
reflection makes aggressive dead-code elimination unsafe.
TinyGo takes a different approach: a different compiler backend, a much simpler garbage collector, and a deliberately restricted subset of the language and standard library. The result for the same trivial program is 25–80 kB compressed, which is the difference between shippable and not.
A first module
TinyGo exports functions with a compiler directive, and the loader glue is the wasm_exec.js shipped
with TinyGo — not the one from the standard Go distribution, which expects a different runtime and fails
in confusing ways.
package main
//export add
func add(a, b int32) int32 {
return a + b
}
//export sum_slice
func sum_slice(ptr *int32, length int32) int32 {
var total int32
slice := unsafe.Slice(ptr, int(length))
for _, v := range slice {
total += v
}
return total
}
func main() {} // required, and must not exit
tinygo build -o dist/engine.wasm -target wasm -no-debug -opt=z ./cmd/engine
cp "$(tinygo env TINYGOROOT)/targets/wasm_exec.js" dist/
brotli -q 11 -c dist/engine.wasm | wc -c
# 48412
-no-debug strips DWARF information and typically halves the output; -opt=z optimises for size. Keep
both for release and drop -no-debug while developing, when a readable stack trace is worth the bytes.
Loading it
<script src="/dist/wasm_exec.js"></script>
<script type="module">
const go = new Go();
const { instance } = await WebAssembly.instantiateStreaming(fetch('/dist/engine.wasm'), go.importObject);
go.run(instance); // starts the runtime; does not return
console.log(instance.exports.add(2, 3)); // 5
</script>
go.run(instance) starts the Go runtime and, for a program whose main does not return, keeps it
running so exported functions remain callable. If main returns, the runtime shuts down and subsequent
calls fail — which is why the empty main above matters and why a main that does work is usually a
mistake in this context.
The syscall/js boundary and what it costs
Go’s browser interop goes through syscall/js, which is dynamic: values are wrapped, property access is
by string name, and conversions happen at runtime. It is pleasant to write and considerably more
expensive per call than a generated binding.
import "syscall/js"
func registerCallbacks() {
js.Global().Set("goHighlight", js.FuncOf(func(this js.Value, args []js.Value) any {
input := args[0].String() // a copy across the boundary
return highlight(input) // returning a string copies back
}))
}
Each args[0].String() copies a string across the boundary and allocates in the Go heap. In a loop that
adds up quickly, and the usual remedy applies: pass a pointer and a length into linear memory, do the
work in one call, and return a pointer and a length. Reserve syscall/js for coarse interactions —
registering a handful of callbacks, reading configuration once — rather than for the hot path.
Getting data in without syscall/js
Since the fast path is a flat buffer, the module needs a way for JavaScript to allocate inside its memory. TinyGo does not export an allocator by default, so you write a small one — and the simplest correct version is a fixed arena.
var buffer [1 << 20]byte // 1 MB, reserved at startup, never grows
//export buffer_ptr
func buffer_ptr() *byte { return &buffer[0] }
//export buffer_cap
func buffer_cap() int32 { return int32(len(buffer)) }
//export process
func process(length int32) int32 {
in := buffer[:length]
out := transform(in) // writes back into buffer
copy(buffer[:], out)
return int32(len(out))
}
const ptr = instance.exports.buffer_ptr();
const cap = instance.exports.buffer_cap();
const view = new Uint8Array(memory.buffer, ptr, cap);
view.set(payload); // one copy in
const outLen = instance.exports.process(payload.length);
const result = new Uint8Array(memory.buffer, ptr, outLen).slice();
A fixed array rather than a dynamically allocated slice matters here: it lives in the module’s data segment at a stable address, so the pointer never changes and the view stays valid for the life of the instance. A slice allocated by the Go runtime could be moved or collected, and a JavaScript view over it would silently read the wrong memory.
The one-megabyte ceiling is a decision, not a limitation of the technique. Size it for the largest input you will accept and reject anything larger explicitly, which is better behaviour than growing the heap mid-call and invalidating every view the page is holding.
What TinyGo gives up
The restrictions are real and it is better to meet them now than in the middle of an implementation.
Reflection is limited. Packages that depend heavily on it — including much of encoding/json in older
versions, and many popular libraries — may not compile or may behave differently. Support has improved
substantially but remains the most common source of “this library does not work with TinyGo”.
Goroutines work, but the scheduler is cooperative and simpler than Go’s. Deeply concurrent code that assumes preemption may behave differently, and there are no operating system threads in a browser anyway.
The standard library is partial. Networking, os, and anything touching processes are absent or stubbed
in the wasm target, as they are for every language here.
Compile times are longer than the standard toolchain’s, sometimes noticeably, because a different backend is doing whole-program optimisation.
Check early: try to build your actual dependencies before committing, because the failure is at build time and the workaround is usually replacing a library rather than tweaking a flag.
Expected output
A release build and a quick sanity check:
tinygo build -o dist/engine.wasm -target wasm -no-debug -opt=z ./cmd/engine
ls -l dist/engine.wasm
# 138_204 dist/engine.wasm
brotli -q 11 -c dist/engine.wasm | wc -c
# 48_412
console:
tinygo runtime started
add(2, 3) = 5
sum_slice(ptr, 10000) = 49995000 in 0.21 ms
If the module is several hundred kilobytes rather than tens, check that -no-debug was applied and that
no dependency pulled in reflection-heavy code — tinygo build -size full reports what is taking the
space.
Gotchas
- The wrong
wasm_exec.js. TinyGo’s and Go’s are not interchangeable; using the wrong one fails at startup with an opaque error. mainreturning. Shuts down the runtime and makes exports uncallable. Keep it empty.- Expecting full reflection. Many libraries assume it; test your dependency tree before committing.
syscall/jsin a loop. Dynamic conversion per call; use a flat memory interface for bulk data.- Garbage collector pauses in a frame loop. TinyGo’s collector is simple and does pause; avoid allocating in a real-time path.
- Debug build shipped by accident. Without
-no-debugthe module is roughly twice the size.
Performance note
A numeric kernel over ten thousand elements ran in 0.21 ms inside the module, while passing the same data
item by item through syscall/js took 42 ms — two hundred times the cost, entirely in the boundary. The
module itself was 48 kB compressed against 1.6 MB for the same program built with the standard toolchain.
Both numbers point the same way: TinyGo plus a flat interface is the combination that makes Go viable in
a browser.
Frequently Asked Questions
Should I use Go for browser modules at all? If the team is a Go team and the logic already exists in Go, yes, with TinyGo. For new code with no such constraint, Rust produces smaller modules with better interop tooling and fewer surprises.
Can I share code between a Go server and a TinyGo browser module? Often, if the shared package avoids reflection, networking and the unsupported standard library corners. Keeping the shared logic in a package with no dependencies beyond the basics makes this work well.
How do I debug a TinyGo module?
Build without -no-debug during development so DWARF information is present, and use the browser’s
WebAssembly debugging extension to step through Go source. Failing that, exporting a logging import and
calling it from strategic points is crude and effective.
Does TinyGo support WASI?
Yes — -target wasi produces a module for standalone runtimes, which is a good fit for edge deployment
and for testing the same logic outside a browser.
Related
- Comparing payload size across languages — where TinyGo lands.
- Choosing between Emscripten, wasm-pack, and TinyGo — toolchain selection in general.
- Encoding strings across the Wasm boundary — the flat interface this page recommends.
One closing recommendation: run tinygo build -size full on every release and record the number. Binary
size in Go regresses quietly when a dependency is added, and the report names the packages responsible.
← Back to Other Languages in the Browser