# Adding Golang to a browser code runner

Ata Kuyumcu adding Golang as the fourth language to [dailyprog](https://dailyprog.club), 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`](https://github.com/laverdet/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](https://wasi.dev) 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:

```js
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](https://github.com/traefik/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:

```js
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.

[[toolbox/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.
