Repository navigation
TinyGo as the size-optimized runtime lane — findings, blocker, and the hard boundary #504
Description
Part of the footprint & constrained-targets epic (#503). #466 adds the standard-Go WASI target and notes in docs/guide/tinygo.md that TinyGo isn't supported, blocked at init on TinyGo 0.41's incomplete reflect. This issue is the fuller picture: a prototype gets much further than the doc-note suggests, and the remaining boundary is specific.
Why TinyGo
It's the size-and-memory lane that standard Go can't reach. On the same program (legmacs), a prototype build measured:
| target | artifact | resident memory |
|---|---|---|
| stock Go, native | 18.9 MB | — |
stock Go, wasm (GOOS=js) |
7.3 MB | — |
| TinyGo, wasm | 1.5 MB | — |
stock Go, native linux/arm64 |
17 MB | 11.5 MB |
TinyGo, native linux/arm64 |
9.9 MB | 3.5 MB |
~5x smaller wasm and ~3.3x less resident memory native, at a ~1.3x compute penalty. That's the case for embedding let-go on edge/constrained devices and in size-sensitive wasm, where stock Go's runtime is the ceiling.
The init-time blocker, and a workaround
TinyGo 0.41's reflect is incomplete: the method-reflection used by NativeFn.Box (variadic detection and call assembly) traps at init, because native-fn boxing runs MustBox at package init and every bundle hits it on boot. Build-tag-splitting that init path — stubbing the reflect-based box so it errors only if invoked, rather than at load — clears the trap. With that plus a couple of goroutine-stack and output-routing fixes, a prototype legmacs builds, boots, and takes input under TinyGo on current main, in both lanes below.
TinyGo's -target=wasm is GOOS=js, so the existing browser host seam (the Go HostWriter output path and the key-source input ring) works unchanged — no new host shim.
Hard boundary: 32-bit int on the wasm lane
TinyGo's wasm int is 32-bit (unsafe.Sizeof(int) == 4), where stock Go's wasm int is 64-bit. let-go's Int is a Go int, so on TinyGo-wasm it's 32-bit. Programs that depend on 64-bit integer width — hash mixing, wrapping multiplies, anything determinism-sensitive — diverge or overflow. A determinism-dependent app (a game keyed on a 64-bit cell hash) builds and renders under TinyGo-wasm but then can't reproduce its world, because the hash arithmetic wraps at 2^32.
This does not affect the TinyGo native lane, whose int is 64-bit, nor programs that don't lean on 64-bit width (legmacs is fine). So the honest scope is: TinyGo is a supported size lane for width-agnostic programs; 64-bit-int-dependent programs need the native TinyGo lane (or stock Go).
Remaining gaps for a real integration
xxh3on native TinyGo. Thexxh3namespace uses arm64 NEON assembly with no pure-Go fallback, so the native TinyGo build currently ships without it. A purego path (or a build-tag scalar fallback) would close it.- Native TinyGo doesn't link on Darwin (TinyGo's
syscallis Linux/GOOS=js-shaped for the calls let-go makes). The wasm lane sidesteps this viaGOOS=js; native TinyGo is a Linux/arm64 story today. - Upstreaming the init split. The
NativeFn.Boxbuild-tag split is the load-bearing change; it's small and reflect-gated, and would want a CI job like Build let-go as a WASI (GOOS=wasip1) module #466'swasip1-buildto keep the TinyGo link from regressing.
Ask
Not a PR yet — surfacing the findings so the target's scope is a decision rather than an assumption. Worth deciding: whether TinyGo is in scope as a supported size lane (with the 64-bit-int boundary documented), and if so whether to take the init split + a build gate as the first slice. Happy to turn the prototype into a reviewable PR if the target is wanted.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsDone
Activity
feat: TinyGo support — small-footprint WASM and native builds (#522)
mparrett commentedon Sep 6, 2026
Re-tested this lane on
origin/main@0911118(2026-09-05), TinyGo 0.41.1, wasmtime 47.0.3, Go 1.26.3. No branches, no cherry-picks: everything below is whatLETGO_USE_TINYGO=1 lg -wdoes today.legmacs works out of the box
1.9 MB self-contained bundle, exit 0. In headless Chromium it boots to its
*scratch*buffer with line numbers, syntax-coloured comments and a full modeline; a typed 14-character marker self-inserts and the modeline tracks toLn 5, Col 15. Zero console errors, zero page errors. Both the output path (HostWriter) and the input path (SAB keysource) work.The
-stack-size=1MBthat this lane needed is now a shipped default in the bundler rather than something a caller passes.xsofy did not — and the cause is not the 32-bit boundary
It builds at 2.0 MB, reaches its title screen, then dies:
pkg/rt/interop_xxh3.gois gated!tinygo, so a TinyGo build has noxxh3namespace at all. A program callingxxh3/*gets nil and fails at the call site. This issue and the guide both describe a substituted hash producing different digests; nothing is substituted.That matters for how the boundary is stated. The wall here was a missing namespace, not integer width.
The 32-bit
intboundary binds less than statedpkg/rt/hash_murmur3.gois untagged, registered on every build, and masks its results to 31 bits so a value is identical on 64-bit native and 32-bit wasm. Its surface mirrors xxh3's, so porting a hash-dependent program is a namespace swap.Swapping xsofy's seed hash to
murmur3(nooga/xsofy#192), the game builds, boots and plays under TinyGo wasm — and its generated world is byte-identical to the stock-Go wasm build at a fixed seed, same sha256 over the rendered dungeon. A 64-bit and a 32-bit build agreeing exactly.So the honest scope is narrower than "64-bit-int-dependent programs need the native lane". A program whose width dependence is confined to its hash can stay on TinyGo wasm by choosing the portable one. Width still binds for programs doing 64-bit arithmetic outside the hash; it does not bind merely because a program is determinism-sensitive.
Sizes, refreshed on current main
3.9x, holding as the game has grown (this issue recorded 7.3 MB against 1.5 MB on a smaller xsofy).
Related
(* 1000000000 1000000000)and(bit-shift-left 1 62)both correct, so that lane is 64-bit and unaffected by any of the above. It has not bit-rotted in the two months since it was written.//go:cgo_import_dynamiclowering for the x/sys trampolines. If it lands it removes the larger half of TinyGo native: upstream the Darwin link fix (libc syscall(2) shim + term sysdep seam) #547's shim; it states explicitly that it does not implement publicsyscall.Syscall*by number, which is the half that would remain.What is still open
The
xxh3-shaped hole is worth a decision rather than a workaround: today a program written againstxxh3/*compiles for TinyGo and fails at runtime. A build-time error, or a registered namespace that fails with a clear message, would both beat nil.mparrett commentedon Sep 27, 2026
Closing as superseded. #522 put the TinyGo lane in the tree, #859 made
Int64-bit on every host, which removes the hard boundary described here, and #860 registersxxh3on TinyGo wasm. The remaining gaps each have their own issue: #546 (alts!onreflect.Select), #547 (Darwin native link), #576 (reflect-free interop bindings), #867 (residual 32-bit narrowing), and #945 (encoding/jsonfootprint).