Dev.to WebDev 🛠 Dev 👁 0 📖 7 min read

How I run Clang and the real Go compiler entirely in the browser

Most online compilers work the same way: your code is sent to a server, compiled inside a container, and the output comes back. That works, but it means running a fleet of sandboxes, paying for them, rate-limiting them a

Most online compilers work the same way: your code is sent to a server, compiled inside a container, and the output comes back. That works, but it means running a fleet of sandboxes, paying for them, rate-limiting them and trusting them with other people's code.

For the online compilers on Devtoolry I went the other way: the compiler itself runs in your browser. C and C++ are compiled by Clang 21 and Go by the official Go 1.27 compiler and linker, all as WebAssembly inside a Web Worker. Nothing is uploaded, and the server only serves static files.

This post covers how it works and the problems I ran into.

The architecture in one picture

page ──{ code, stdin }──▶ Web Worker
                           ├─ download toolchain once (cached by the browser)
                           ├─ compile + link in an in-memory file system
                           ├─ WebAssembly.compile(main.wasm)
                           └─ run it with a tiny WASI host (stdin/stdout/stderr, clocks)
page ◀──{ output, exitCode }─┘

Each language has its own worker. The page enforces the time limit: if a program loops forever, it calls worker.terminate() and starts a fresh worker. That one call is the whole "kill the process" story, which is far simpler than a server-side sandbox.

The compiled program targets WASI (wasm32-wasi for C/C++, GOOS=wasip1 for Go). WASI gives a program a POSIX-like set of imports (fd_write, fd_read, clock_time_get, random_get, proc_exit…), and the worker decides what they do. Mine maps fd 0 to the stdin box, fds 1 and 2 to the output panel, and leaves out networking entirely.

C and C++: Clang 21 in a worker

I didn't compile LLVM myself. YoWASP publishes Clang and LLD built for WebAssembly as an npm package, with a WASI sysroot. The worker imports it from jsDelivr on the first run (about 23 MB compressed) and the browser caches it after that.

Compiling is a function call that takes a virtual file tree and returns one:

const toolchain = await import('https://cdn.jsdelivr.net/npm/@yowasp/[email protected]/gen/bundle.js');

const output = await toolchain.commands['clang'](
  ['-std=c17', '-O2', '-Wall', '-fno-color-diagnostics', 'main.c', '-lm', '-o', 'main.wasm'],
  { 'main.c': code },
  { stdout, stderr, decodeASCII: false },
);
const binary = output['main.wasm'];

The resulting main.wasm runs with a WASI host of about 140 lines: stdin from a byte array, stdout and stderr posted back to the page, clocks and random_get from crypto.getRandomValues. Every other import returns ENOSYS. There is no file system, so fopen() simply fails with an error code, as it would in a locked-down container.

A plain C program compiles in well under a second. The C standard is just a flag, so the page offers C89, C99, C11, C17 and C23.

Problem 1: C++ exceptions don't link

The WASI sysroot's libc++abi has no exception runtime, so any program that uses throw fails to link, and plenty of the standard library throws. My fix is a small stub, compiled once, that does what Emscripten does by default: print the exception type and abort.

extern "C" void __cxa_throw(void *, std::type_info *type, void (*)(void *)) {
  int status = 0;
  const char *raw = type ? type->name() : "unknown";
  char *name = __cxa_demangle(raw, nullptr, nullptr, &status);
  std::fflush(stdout);
  std::fprintf(stderr, "terminate called after throwing an instance of '%s'\n", name ? name : raw);
  std::abort();
}

try/catch still compiles, but a throw ends the program with the same message GCC's runtime prints for an uncaught exception. It's an honest limitation, and the page says so.

Problem 2: #include <iostream> takes 5–9 seconds

C compiles fast. C++ didn't: parsing <iostream>, <vector>, <map> and friends took 5–9 seconds on every run in WebAssembly.

The fix is a precompiled header. A build script includes ~40 common standard headers (the same set people expect from GCC's <bits/stdc++.h>) and saves a .pch built by the exact same Clang version with the same flags. The worker downloads it in the background on the first C++ run, then compiles with -include-pch:

clang('clang++', ['-include-pch', 'cxx20-o2.pch', ...args], { ...files, ...pchFiles });

That cut a typical C++ compile to 1–3 seconds. Two details mattered:

  • The first run never waits for the PCH. It compiles normally while the PCH downloads, and later runs pick it up.
  • A version check. A manifest records the Clang version the PCH was built with. If it doesn't match the compiler that loaded, or a compile fails with PCH-related errors, the worker retries without it. A stale PCH must never break someone's code.

The header list doubles as <bits/stdc++.h>, so competitive-programming solutions compile unchanged.

Problem 3: compiler output arrives one byte at a time

Clang's stderr reached my callback in tiny fragments, each followed by a flush. Posting a message per fragment flooded the page, so the worker buffers by line and only posts complete lines.

Go: the real compiler, not an interpreter

For Go I tried Yaegi first, a Go interpreter written in Go that compiles to WebAssembly easily. It wasn't good enough for this. Generic type inference failed on ordinary code, newer built-ins like min and max were missing, os.Exit didn't behave, and deadlocks hung instead of panicking. People paste real Go and expect go run behaviour.

So the site uses Go's own cmd/compile and cmd/link, built for GOOS=wasip1. Since the Go toolchain is written in Go, this takes one build command per tool:

GOOS=wasip1 GOARCH=wasm go build -trimpath -ldflags=-s -o compile.wasm cmd/compile
GOOS=wasip1 GOARCH=wasm go build -trimpath -ldflags=-s -o link.wasm cmd/link

The compiler needs a precompiled standard library to link against. The whole of it is ~140 MB, so the build script compiles only the packages a playground program realistically imports (fmt, strings, sort, slices, maps, encoding/json, math/big, regexp, time, sync…) plus everything they depend on. It packs them into one file: a little-endian length, a JSON index, then the .a archives back to back. That comes to about 42 MB, or 10 MB gzipped.

In the browser, an in-memory file system (@bjorn3/browser_wasi_shim) holds main.go, the archives and an importcfg that maps each import path to its archive:

const importcfg = std.map(([path], i) => `packagefile ${path}=/pkg/${i}.a`).join('\n');

await run(compile, ['compile', '-o', 'main.a', '-p', 'main', '-lang=go1.27', '-complete',
  '-trimpath=/work', '-importcfg', 'importcfg', '-pack', 'main.go']);
await run(link, ['link', '-s', '-w', '-o', 'main.wasm', '-importcfg', 'importcfg',
  '-buildmode=exe', 'main.a']);

This is what go build does under the hood. The output is a real wasip1 binary, so compile errors, panics with stack traces and the all goroutines are asleep - deadlock! detector all behave exactly as they do locally.

The total first download is about 23 MB compressed. The files sit in a folder named after the Go version, so a browser never mixes cached files from two releases.

Problem 4: poll_oneoff and Go's scheduler

The first Go programs that used time.Sleep or goroutines with timers crashed or hung. The cause: Go's runtime calls WASI's poll_oneoff with several subscriptions at once (multiple clocks, sometimes file descriptors), while the shim only handled a single clock.

I replaced poll_oneoff with my own version. File-descriptor subscriptions are always ready (stdin is a byte array, so nothing ever blocks). For clocks, it finds the earliest deadline and waits for it:

const deadline = subscriptions.reduce((min, s) => (s.deadline < min ? s.deadline : min), first);
while (now() < deadline);  // spin

Yes, that's a busy-wait. A worker can only block properly with Atomics.wait, which needs SharedArrayBuffer, which needs cross-origin isolation (COOP/COEP headers). That would break other things on the site, such as ads and third-party embeds. Burning one core inside a worker for the length of a time.Sleep is an acceptable trade, and the page's timeout still kills runaway programs.

Problem 5: file paths in stack traces

Go's wasip1 port takes its working directory from the PWD environment variable, and stack traces print full paths. Compiling inside /work with PWD=/work and -trimpath=/work makes a panic say main.go:12 instead of a long virtual path, just like go run main.go.

The other languages

Not everything needed this much work. Mature WebAssembly builds already exist for these:

  • Ruby 4.0: the official CRuby WASI build (@ruby/4.0-wasm-wasi).
  • R 4.6: webR. It spawns its own nested worker, so the worker CSP needed worker-src blob:.
  • Python: Pyodide.
  • PHP 8.4: php-wasm.

Each sits behind the same message protocol (run → status / output / done), so the page doesn't care which language it is talking to.

Trade-offs, honestly

  • First run cost: 9–23 MB depending on the language, once. On a slow mobile connection that's noticeable; the C/C++ compiler reports download progress while it loads.
  • No exceptions in C++: a throw aborts, as described above.
  • Partial standard library in Go: a missing package gives a clear "not available in this sandbox" message listing the packages that are.
  • No network or real files: by design.
  • CPU-heavy programs run on the user's machine. That's the point (it costs me nothing), but a phone is slower than a server.

In return there's no server-side code execution at all. That means no container escapes to worry about and no per-run costs, and the compilers keep working if my backend goes down, because they're just static files.

Try it

If you find a program that behaves differently from your local toolchain, I'd love to hear about it in the comments. Those edge cases are how most of the fixes above were found.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.