Adding Golang to a browser code runner
- title
- Adding Golang to a browser code runner
- type
- summary
- summary
- GOOS=js hangs inside a V8 isolate; GOOS=wasip1 plus a 50-line WASI shim runs Yaegi instead
- tags
- go, webassembly, sandboxing, javascript
- created
- 2026-07-23
- updated
- 2026-09-01
Ata Kuyumcu adding Golang as the fourth language to dailyprog, a daily coding-puzzle site that runs user-submitted code. JavaScript was native, Python came via Pyodide, C via a PicoC WASM binary with a custom printf harness. Every Go-to-WASM tutorial points at GOOS=js GOARCH=wasm, and that turned out to be the wrong target for this particular host.
Why GOOS=js fails outside a browser
dailyprog runs submissions inside isolated-vm β a bare V8 isolate with no DOM, no event loop, and a restricted API surface. The standard wasm_exec.js boot recipe calls WebAssembly.instantiate(), which returns a Promise. Inside the isolate nothing drives the microtask queue, so the Promise never settles and the program hangs until it times out. The visible symptom was Maximum call stack size exceeded, which sent the author chasing V8 stack sizes for two days; --stack-size doesn't reach isolates anyway, and the overflow was a downstream effect of the hung boot.
The second problem would have killed it even if the first were solvable. The syscall/js bridge works by setting properties on globalThis. In a browser that's window. In an isolate, the globalThis the bridge captured at boot isn't the one the guest code sees, so callbacks fire into nothing. The bridge is compiled into the WASM, so there's nothing to patch from the host side.
GOOS=js assumes a full browser, and fails in ways nobody documented because almost nobody runs Golang WASM inside a V8 isolate.
WASI is the right size
GOOS=wasip1 GOARCH=wasm has been a Golang target since 1.21. WASI is a much smaller contract than the JS bridge: a linear memory buffer and a set of imported functions for file I/O, clocks, environment, and randomness. No Promises, no event loop, no globalThis. fmt.Println becomes a call to fd_write, and implementing fd_write is the host's whole job:
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;
}
Each iovs entry is an {offset, length} pair in linear memory; decode UTF-8, strip the newline Println added, report the byte count back through nwritten because Golang checks it. The rest are near-trivial: args_get hands the user's source in as argv[1], clock_time_get returns performance.now() in nanoseconds, random_get fills a buffer from Math.random(), proc_exit throws with a sentinel prefix so normal exit is distinguishable from a crash, file-descriptor calls return EBADF, and everything else returns ENOSYS (52). About fifty lines of host code total.
The guest is Yaegi, Traefik's Golang interpreter written in Golang, compiled to wasip1. The wrapper is roughly 30 lines: interp.New, load stdlib.Symbols, read os.Args[1], Eval. The resulting binary is 38 MB.
The single-shot trap
WASI's _start runs once. Call it a second time on the same WebAssembly.Instance and the Golang runtime dies with fatal error: randinit twice, or fatal error: self deadlock, or β the version that costs you an afternoon β nothing at all, just Yaegi expiring before any fmt.Println fires and the runner reporting "No output for this case."
The fix separates the two expensive-vs-cheap halves of WASM startup. Compiling a 38 MB module is the expensive part, so cache the WebAssembly.Module; instantiating is cheap, so make a fresh Instance per run:
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; // the shims must follow the new memory
instance.exports._start();
}
The easily-missed line is the memory reassignment β the WASI shims close over a DataView on linear memory, and pointing it at the previous instance's memory gives you garbage rather than an error. On the server side the equivalent is a fresh isolated-vm Isolate per run, at about 4 seconds cold boot.
What it cost, and the pattern
Because dailyprog had already unified all four languages onto one codegen / run / split-output / evaluate pipeline, the Golang-specific work was seven files (three tests, one of them the 38 MB binary) plus one-line entries in the shared ones. The server-side GoSandbox is a copy of CSandbox, about 20 lines. The browser runner is a 130-line module that the Web Worker imports lazily. Harness codegen emits a main() with hardcoded Golang literals and fmt.Println(prefix, call) per test case, with type inference mapping JS values to int, float64, string, bool, and slices, and a parseGoLine converting results back for deepEqual.
The generalization the author draws: any language that can compile a self-hosted interpreter to wasm32-wasip1 slots into the same shape. A Lua interpreter in C via clang, a Ruby interpreter in Rust, PHP's existing third-party WASI builds. WASI isn't elegant β it's a POSIX-ish syscall interface β but the contract is small enough to implement correctly in an afternoon, and there is no second runtime on the far side of a bridge demanding its own event loop.
Related
webassembly for the underlying target. wazero is the mirror image of this setup β a WASI-capable host written in Golang rather than a Golang guest running on a JS host β and the same "exported imports are your sandbox boundary" reasoning applies to both.
antonz-org is the blog behind Codapi, an interactive-code-examples platform with the same job as dailyprog's runner, and its "Try X in Y minutes" guides are what a runner like this exists for.
wanix arrives at the same conclusion by construction: it keeps gojs and wasi as separate task drivers rather than one Wasm driver, because the two targets have different host contracts and picking the wrong one is this failure.