Overview
This guide dissects a TinyGo WebAssembly demo that runs two compute-intensive workloads directly in the browser: a 12,000+ particle fluid physics simulator and a Mandelbrot fractal renderer. The project is compiled with TinyGo in C-Shared Reactor Mode and consumed by JavaScript through a zero-copy linear-memory bridge.
Key Points
- Two high-concurrency demo workloads
- 12,000+ particle fluid collision engine: real-time position updates, Euclidean repulsion from the cursor within radius R, and damping with gamma = 0.98.
- Mandelbrot fractal renderer: per-pixel complex iteration Z_{n+1} = Z_n^2 + C with smooth sine-rainbow coloring based on escape iteration count.
- Native C-Shared Reactor architecture
- The WASM module is compiled without a
main()event loop. Go functions marked with//exportare exposed as native symbols that JS can call synchronously. - No
wasm_exec.jsglue layer is required; the module is loaded via a standardWebAssembly.instantiatecall. - Zero-copy shared linear memory
- Go allocates particle buffers and exports a raw pointer via
//export getParticleDataPtrreturninguintptr(unsafe.Pointer(&particleData[0])). - JavaScript wraps
WebAssembly.Memory.bufferin a typed array:new Float32Array(exports.memory.buffer, ptr, particleCount * 6). - This removes
syscall/jsreflection and JSON-style marshalling, yielding a reported 5x to 10x data-transfer speedup and effectively 0 ms transfer time. - Pitfall 1 — Missing
wasm-opton Windows - Error:
could not find wasm-opt. - Cause: Binaryen is not installed.
- Fix: a
wasm-opt.batstub script that answers--versionwithversion 116, passes through arguments, and copies the input file to the--outputtarget. - Pitfall 2 —
panic: scheduler is disabled - Cause: a channel receive
<-ctriggerstask.Pauseunder-scheduler=none. - Fix: remove blocking channel operations; rely on exported functions instead.
- Pitfall 3 —
Go program has already exited - Cause:
main()returns and TinyGo setsgo.exited = true. - Fix: rebuild with
-buildmode=c-sharedto switch to Reactor Mode and drop the need for a long-runningmain(). - Pitfall 4 —
LinkError: Import #11 "asyncify" "stop_rewind" - Cause: TinyGo implicitly pulls in Binaryen's asyncify transforms.
- Fix: explicitly pass
-scheduler=noneso the produced WASM has zero asyncify imports and no longer depends onwasm_exec.jsrewind logic. - Pitfall 5 —
LinkError: Import #1 "wasi_snapshot_preview1" - Cause: Go's runtime initialization calls
wasi_snapshot_preview1.random_get, which browsers do not provide. - Fix: an ES6
Proxyadapter that implementsrandom_getviacrypto.getRandomValuesoverWebAssembly.Memory, and falls back to a no-op returning0for any undefined WASI syscall. - Final benchmark comparison vs standard Go (gc) WASM
- WASM size: ~3.5 MB (3,670,012 B) → 105.9 KB (108,513 B), a 97% reduction.
- JS glue dependencies: required
wasm_exec.js(~15 KB) → zero external dependencies. - Memory transfer: serialized/reflection copies → zero-copy pointer view.
- Rendering at 12,000 particles: 30–45 FPS → stable 60 FPS.
- Engineering takeaways
- TinyGo is viable for browser-side high-performance plugins, image algorithm libraries, and edge-style compute modules thanks to its tiny footprint.
- The combination of
//exportplus-buildmode=c-sharedlets Go source be packaged as a standard C/C++-style WASM module while still benefiting from zero-copy shared memory.
FAQ
Q1: Who is this for? Engineers and researchers working on compute-heavy browser workloads, WebAssembly toolchains, and edge or graphics compute who want a compact, dependency-free path from Go to the browser.
Q2: What are the core takeaways?
Reactor Mode plus a uintptr to Float32Array bridge gives a 5x–10x transfer speedup; explicit -scheduler=none strips asyncify imports; an ES6 Proxy cleanly absorbs missing WASI calls; and the resulting binary is roughly 97% smaller than standard Go's WASM output while hitting a stable 60 FPS at 12,000 particles.
Q3: Is source code available?
See the wasm-opt.bat shim and index.html Proxy adapter snippets referenced inline in the original article.