Ata Kuyumcu's Blog

Adding Go to a browser code runner

#wasm#go#programming
Adding Go to a browser code runner

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_get passes the source code as argv[1]
  • clock_time_get returns performance.now() in nanoseconds
  • random_get fills a buffer with Math.random() bytes
  • proc_exit throws 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.