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.js from 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.

The same program, two toolchains The standard toolchain compiles the full Go runtime including its scheduler and reflection metadata. TinyGo uses a simpler runtime and a restricted subset, producing a module one to two orders of magnitude smaller. GOOS=js GOARCH=wasm full runtime: scheduler, GC, reflection metadata — 1.6 MB code tinygo build -target wasm runtime code 48 kB total The saving comes from a simpler collector and a restricted language subset, not from optimisation flags — it is a different compiler, not a smaller setting.

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.

Two boundaries, very different costs Accessing JavaScript values through syscall/js copies and converts on every call. Passing a pointer and a length into linear memory crosses once per batch and lets Go read the bytes directly. syscall/js per item wrap, convert, copy, unwrap allocates in the Go heap 10,000 items → 10,000 crossings ≈ 42 ms pointer and length, once one copy into linear memory Go reads the bytes in place 10,000 items → 1 crossing ≈ 1.4 ms The same advice as every other language here, with a steeper penalty for ignoring it because Go's boundary is dynamic rather than generated.

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.

What the smaller runtime gives up The standard toolchain ships a full runtime, scheduler and collector. TinyGo ships a much smaller one, at the cost of some reflection and some library support. go build about 2.1 MB — full runtime, scheduler and collector tinygo build 230 KB smaller collector; some reflection and packages unsupported Check the package support list before committing — an unsupported dependency is found at link time. Goroutines work under TinyGo, but the scheduler is simpler and behaviour under heavy concurrency differs.

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.
  • main returning. 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/js in 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-debug the 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.

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