In working through various issues of getting BubbleTea to work with WASM (navigating a dependency tree I'm still upstreaming), I was also testing it against tinygo. Once I "got it working" initially, I hit a snag with some of BubbleTea's widgets ("bubbles"). The way they use the delegate pattern causes a diabolical parameter explosion. The stock Go compiler optimizes it away, but tinygo doesn't. In practice, this makes a small List widget unable to fit within a WASM runtime.
After discovering this, I worked through it with LLMs. I've sat on it ensuring I understood it better, sorry I didn't at least open the issue but I also didn't want to hand you garbage. Interestingly over time (a couple months), TinyGo added support for maxDirectAggregateValues spilling machinery (#5526), so I since reworked to use that. I hope that discussion and that well-worked change makes this easier to digest.
I have a PR forthcoming. The text below is LLM-generated, but I reviewed it all. These edges are very interesting, making me appreciate compiler underpinnings more and more, but also are not my forte.
What happened
The WebAssembly core spec places no limit on function parameter counts, but
the JS embedding caps them at 1,000 (https://webassembly.github.io/spec/js-api/#limits)
— browsers enforce this at WebAssembly.Module compile time, and
wasmparser-based tooling (wasm-tools, and runtimes built on wasmparser) rejects
such modules too.
TinyGo's wasm output can exceed this cap: Go structs passed by value are
re-scalarized by LLVM's WebAssembly backend into one wasm param per scalar
leaf, and nothing bounds the per-function total.
Minimal reproduction (verified on dev @ f71b630 and 86d58db)
package main
import "os"
// 600 scalar leaves per value: below the compiler's 1024 per-aggregate
// indirect threshold, so each parameter is passed by value.
type s10 struct{ f0, f1, f2, f3, f4, f5, f6, f7, f8, f9 int32 }
type s100 struct{ a0, a1, a2, a3, a4, a5, a6, a7, a8, a9 s10 }
type s600 struct{ b0, b1, b2, b3, b4, b5 s100 }
//go:noinline
func f(x, y s600) int32 { return x.b0.a0.f0 + y.b5.a9.f9 }
func main() {
var x, y s600
x.b0.a0.f0 = int32(len(os.Args))
y.b5.a9.f9 = int32(len(os.Environ()))
println(f(x, y))
}
$ GOOS=js GOARCH=wasm tinygo build -o repro.wasm .
Some VMs may not accept this binary because it has a large number of
parameters in function main.f.
$ wasm-tools print repro.wasm
error: function params size is out of bounds (at offset 0x85)
main.f gets a wasm function type with 1,200 i32 parameters (2 × 600 leaves).
Binaryen only warns; browsers and wasm-tools reject the module outright.
Where this bites in practice
This was found compiling Bubble Tea / charm TUIs to wasm
(charmbracelet/bubbletea#1698). A lipgloss.Style flattens to ~95 scalar
leaves and bubbles/list.Model transitively holds ~40 of them, so
value-receiver methods used to produce types like (list.Model).Update with
3,730 params and (list.DefaultDelegate).Render with 4,316 — Safari:
CompileError: WebAssembly.Module doesn't parse at byte 103:
argument count of Type at index 14 is too big 3730 maximum 1000
Current dev improved this: maxDirectAggregateValues = 1024
(compiler/func.go, added by #5526 to keep giant single aggregates from
overflowing SelectionDAG's 65,535-value representation limit, cf. #5477)
passes any single aggregate above 1,024 leaves
indirectly. That pulled the bubbles examples down a lot — but it bounds
per-aggregate values, not the per-function total the JS embedding actually
limits. Measured on dev (both f71b630 and current 86d58db): the bubbles
list-fancy example's largest function type has 995 params — five below
the browser cap. One more lipgloss.Style anywhere in a model (≈95 leaves)
pushes a real app back over the limit, and the repro above shows two modest
600-leaf params already fail.
(maxFieldsPerParam = 3 in compiler/calls.go doesn't help here: a struct
that flattens to more than 3 fields is passed as a single LLVM struct-by-value
parameter, which the wasm backend then expands anyway. Return values are
fine — they're already lowered through sret out-params, cf. #2512. Only the
parameter side is affected.)
Proposed fix
Extend #5526's approach — preemptively indirect aggregates before LLVM sees
them — with a lower threshold for this stricter, external limit: spill
aggregate parameters above a small leaf threshold (16) through backing
storage in the Go-internal ABI, reusing dev's existing indirect-value
machinery (copyToIndirectStorage, goroutine argument handling), keeping the
exported/C ABI unchanged (the same scope decision #5526 made). This bounds each parameter's contribution to 16
scalars rather than enforcing a whole-signature budget — a practical
mitigation: exceeding 1,000 then requires a function with more than 62
parameters, which real Go signatures don't approach (the offenders here are
single receivers with thousands of leaves). Exported/C-ABI functions are out
of scope, since their externally visible ABI cannot be respilled. I have a
branch implementing this with golden-IR and GC-stressed behavioral tests;
with it, the bubbles list-fancy example's largest function type drops from
995 params to 17, and the repro above builds a module that wasm-tools validate accepts. PR incoming.
Environment
- TinyGo:
tinygo version 0.42.0-dev darwin/arm64 (using go version go1.26.7 and LLVM version 22.1.8) — built from dev at f71b630, re-verified at
86d58db
- Target:
GOOS=js GOARCH=wasm (the scalarization is target-independent for
wasm; wasip1 modules hit the same rejection in wasmparser-based runtimes)
- wasm-tools 1.257.1 (homebrew), Binaryen bundled with TinyGo
In working through various issues of getting BubbleTea to work with WASM (navigating a dependency tree I'm still upstreaming), I was also testing it against
tinygo. Once I "got it working" initially, I hit a snag with some of BubbleTea's widgets ("bubbles"). The way they use the delegate pattern causes a diabolical parameter explosion. The stock Go compiler optimizes it away, buttinygodoesn't. In practice, this makes a small List widget unable to fit within a WASM runtime.After discovering this, I worked through it with LLMs. I've sat on it ensuring I understood it better, sorry I didn't at least open the issue but I also didn't want to hand you garbage. Interestingly over time (a couple months), TinyGo added support for
maxDirectAggregateValuesspilling machinery (#5526), so I since reworked to use that. I hope that discussion and that well-worked change makes this easier to digest.I have a PR forthcoming. The text below is LLM-generated, but I reviewed it all. These edges are very interesting, making me appreciate compiler underpinnings more and more, but also are not my forte.
What happened
The WebAssembly core spec places no limit on function parameter counts, but
the JS embedding caps them at 1,000 (https://webassembly.github.io/spec/js-api/#limits)
— browsers enforce this at
WebAssembly.Modulecompile time, andwasmparser-based tooling (wasm-tools, and runtimes built on wasmparser) rejects
such modules too.
TinyGo's wasm output can exceed this cap: Go structs passed by value are
re-scalarized by LLVM's WebAssembly backend into one wasm param per scalar
leaf, and nothing bounds the per-function total.
Minimal reproduction (verified on
dev@ f71b630 and 86d58db)main.fgets a wasm function type with 1,200 i32 parameters (2 × 600 leaves).Binaryen only warns; browsers and wasm-tools reject the module outright.
Where this bites in practice
This was found compiling Bubble Tea / charm TUIs to wasm
(charmbracelet/bubbletea#1698). A
lipgloss.Styleflattens to ~95 scalarleaves and
bubbles/list.Modeltransitively holds ~40 of them, sovalue-receiver methods used to produce types like
(list.Model).Updatewith3,730 params and
(list.DefaultDelegate).Renderwith 4,316 — Safari:Current
devimproved this:maxDirectAggregateValues = 1024(compiler/func.go, added by #5526 to keep giant single aggregates from
overflowing SelectionDAG's 65,535-value representation limit, cf. #5477)
passes any single aggregate above 1,024 leaves
indirectly. That pulled the bubbles examples down a lot — but it bounds
per-aggregate values, not the per-function total the JS embedding actually
limits. Measured on
dev(both f71b630 and current 86d58db): the bubbleslist-fancyexample's largest function type has 995 params — five belowthe browser cap. One more
lipgloss.Styleanywhere in a model (≈95 leaves)pushes a real app back over the limit, and the repro above shows two modest
600-leaf params already fail.
(
maxFieldsPerParam = 3in compiler/calls.go doesn't help here: a structthat flattens to more than 3 fields is passed as a single LLVM struct-by-value
parameter, which the wasm backend then expands anyway. Return values are
fine — they're already lowered through sret out-params, cf. #2512. Only the
parameter side is affected.)
Proposed fix
Extend #5526's approach — preemptively indirect aggregates before LLVM sees
them — with a lower threshold for this stricter, external limit: spill
aggregate parameters above a small leaf threshold (16) through backing
storage in the Go-internal ABI, reusing dev's existing indirect-value
machinery (
copyToIndirectStorage, goroutine argument handling), keeping theexported/C ABI unchanged (the same scope decision #5526 made). This bounds each parameter's contribution to 16
scalars rather than enforcing a whole-signature budget — a practical
mitigation: exceeding 1,000 then requires a function with more than 62
parameters, which real Go signatures don't approach (the offenders here are
single receivers with thousands of leaves). Exported/C-ABI functions are out
of scope, since their externally visible ABI cannot be respilled. I have a
branch implementing this with golden-IR and GC-stressed behavioral tests;
with it, the bubbles
list-fancyexample's largest function type drops from995 params to 17, and the repro above builds a module that
wasm-tools validateaccepts. PR incoming.Environment
tinygo version 0.42.0-dev darwin/arm64 (using go version go1.26.7 and LLVM version 22.1.8)— built fromdevat f71b630, re-verified at86d58db
GOOS=js GOARCH=wasm(the scalarization is target-independent forwasm; wasip1 modules hit the same rejection in wasmparser-based runtimes)