dailyprog (the daily coding puzzle site, previously) started with JavaScript. Then Python, then C. Each new language was a different kind of project. Python needed Pyodide. C needed a PicoC WASM binary and a custom harness for printf output. Go was supposed to be the fourth. Every Go-to-WASM tutorial points at the same path, so I took it.
It was the wrong one.
The path everyone takes: GOOS=js
Compiling Go to WASM for the browser means setting GOOS=js GOARCH=wasm. Go
ships two pieces for this: a compiler target that produces WASM with a
JavaScript syscall bridge, and
wasm_exec.js, a support file that
implements the Go runtime’s expectations on the JS side. You load wasm_exec.js,
you call WebAssembly.instantiate(), the Go runtime boots, and your program
runs.
This is how most in-browser Go playgrounds work (LiveCodes, for one; the official playground sidesteps the problem entirely by running your code on a server). It works, and there are years of Stack Overflow answers behind it.
The problem is that dailyprog runs user-submitted code inside
isolated-vm, not in the main
browser thread. isolated-vm is a V8 isolate with no DOM, no event loop, and a
restricted API surface. And the standard boot recipe uses async
WebAssembly.instantiate(), which returns a Promise. Inside isolated-vm, that
Promise never settles, because nothing in the isolate drives it. The program
hangs until the call times out.
I spent two days on variations of this. The error said
Maximum call stack size exceeded, so I chased stack overflows and increased
the V8 stack size. No effect (--stack-size doesn’t even reach isolates, and
the overflow was a symptom of the hung boot anyway). I tried injecting
setTimeout polyfills into the isolate. Nothing worked. Async initialization in
a synchronous environment is a dead end.
There’s a second problem with GOOS=js. Even if the async issue were solvable,
the syscall/js bridge (the mechanism Go uses to call into JavaScript and vice
versa) relies on setting properties on globalThis. In a browser, globalThis
is the window. In isolated-vm, the globalThis the bridge captured at boot
isn’t the one your code sees, so the callbacks fire and nothing receives them.
You can’t patch it from the outside; the bridge is compiled into the WASM.
GOOS=js assumes a full browser. Take that away and it fails in ways nobody has written down, because almost nobody runs Go WASM inside a V8 isolate.
The clean path: GOOS=wasip1
WASI is a system interface for WASM. It defines a set of
imports that a WASM module can call (file I/O, environment variables, clock,
random numbers) and the host provides implementations. It’s a much smaller
contract than the JS bridge. No Promises, no event loop, no globalThis. Just a
linear memory buffer and a handful of function imports that read from and write
to it.
Go supports WASI as a build target since 1.21:
GOOS=wasip1 GOARCH=wasm. When you compile with this, Go’s fmt.Println writes
to stdout via WASI’s fd_write import. Your job as the host is to implement
fd_write and capture those writes.
Here’s the whole host shim for fd_write:
function fd_write(fd, iovs, iovs_len, nwritten) {
let written = 0;
for (let i = 0; i < iovs_len; i++) {
const ptr = mem.getUint32(iovs + i * 8, true);
const len = mem.getUint32(iovs + i * 8 + 4, true);
const text = readStr(ptr, len);
stdout.push(text.endsWith("\n") ? text.slice(0, -1) : text);
written += len;
}
mem.setUint32(nwritten, written, true);
return 0;
}
That’s it. The function reads iovs entries from linear memory (each an
{offset, length} pair), decodes the UTF-8 bytes, strips the trailing newline
that fmt.Println adds, pushes the result to a stdout array, and reports the
byte count back through nwritten (Go checks it). The rest of the WASI imports
are trivial:
args_getpasses the source code as argv[1]clock_time_getreturnsperformance.now()in nanosecondsrandom_getfills a buffer withMath.random()bytesproc_exitthrows with a sentinel prefix so we can distinguish normal exit from crash- Everything else returns an error: EBADF for file descriptor calls, ENOSYS (52) for the rest
The whole host side is about fifty lines. The Go program compiles to WASM, boots
in the isolate, runs the user’s code via
Yaegi (a Go interpreter written in Go), and
fmt.Println output shows up in stdout.
The runtime is a single WASM binary: Yaegi compiled to WASI. Build recipe:
go mod init yaegi-wasi
go get github.com/traefik/yaegi@v0.16.1
GOOS=wasip1 GOARCH=wasm go build -o yaegi.wasm yaegi_wasi.go
The wrapper is about 30 lines of Go: create an interpreter with interp.New,
load stdlib.Symbols into it, read source from os.Args[1], Eval it. No
special compilation tricks. The resulting WASM is 38 MB.
The single-shot trap
There’s one catch. WASI _start is single-shot. You call it once, the program
runs to completion, and the runtime tears down. If you call _start again on
the same WebAssembly.Instance, Go panics:
fatal error: randinit twice
Or:
fatal error: self deadlock
Or, the worst version: no error at all. Yaegi dies before fmt.Println fires
and the runner reports "No output for this case." Nothing in that message
points at instance reuse.
The fix: fresh instance per run. And on the server side, fresh isolated-vm
Isolate per run (about 4 seconds cold boot). In the browser, you cache the
compiled WebAssembly.Module (compiling a 38 MB WASM binary on every run would
be painful) but create a fresh Instance each time:
// WRONG: reuse instance
let instance = null;
export async function runGo(source, callback) {
if (!instance) instance = new WebAssembly.Instance(mod, imports);
instance.exports._start(); // fails on second call
}
// RIGHT: cache module, fresh instance
let mod = null;
export async function runGo(source, callback) {
if (!mod) mod = await WebAssembly.compile(bytes);
const instance = new WebAssembly.Instance(mod, imports);
memory = instance.exports.memory; // update the shim's memory reference
instance.exports._start(); // fresh runtime each time
}
The compiled module is the expensive part. The instance is cheap. The equivalent
of spawning a new process from a cached binary. The key detail is updating the
memory reference in the WASI shims so they read from and write to the new
instance’s linear memory, not the previous one’s.
The result
Adding Go came after a harness unification: all four languages share one codegen, run, split-output, evaluate pipeline. The Go-specific part is seven new files (three of them tests, one of them the 38 MB binary) plus one-line entries in the shared ones. The GoSandbox class in the server is a copy-paste of CSandbox: generate code via the harness, run via the runtime, split output, evaluate results. About 20 lines.
The browser runner is a 130-line module (go-runtime.mjs) that fetches the
WASM, implements the same WASI shims, and exposes runGo(source, onLine). The
Web Worker imports it lazily.
The harness codegen generates a main() with hardcoded Go literal args and
fmt.Println(prefix, call) per test case. Same structure as the C harness. Type
inference maps JavaScript values to Go types (int, float64, string, bool, and
slices). The parseGoLine function converts result strings back to JS values
for deepEqual comparison.
All of the mechanical wiring (the LangAdapter interface, splitOutput,
evaluateResults) was already in place from the unified harness. Adding a
language that targets WASI is now a fill-in-the-blanks exercise. The only hard
part is finding an interpreter that compiles to WASM and proving it works inside
isolated-vm.
The WASI pattern
WASI is the right size for this job. It isn’t elegant (it’s a POSIX-ish syscall
interface) but the contract is small enough to implement correctly in an
afternoon: fd_write, args_get, and a dozen stubs. There’s no second runtime
on the other side of a bridge wanting its own event loop.
Every language added to dailyprog after this that needs a WASM runtime should
target WASI. Self-hosted interpreters (interpreters written in their own
language, compiled to WASM) work as long as the language can compile to
wasm32-wasip1. Yaegi proved the pattern for Go. A Lua interpreter written in C
(targeting WASI via clang) would work the same way. So would a Ruby interpreter
written in Rust, or PHP, which already has third-party WASI builds.
GOOS=js is the first thing that works in the browser, and the Go team maintains it, which is comforting right up until you leave the browser. WASI drops the browser assumption and gives you a program that reads input and writes output. For a code runner, that’s the entire job description.
