JS-in-WASM vs. Compiled Go: What We Learned Running User Code at Scale
One of the hardest problems in building a multi-tenant platform is safely running user-defined code. You need isolation a bug or infinite loop in one tenant’s code must not affect another’s. You need portability users write in different languages. And you need speed because every millisecond of execution overhead compounds across thousands of requests.
We landed on WebAssembly (WASM) as our sandboxing layer. WASM gives you a hardened, memory-isolated execution environment that runs at near-native speed and is fundamentally language-agnostic. Our host is written in Elixir, with a Rust sidecar responsible for orchestrating a pool of WASM instances via Wasmtime.
When it came time to choose how to package and execute logic inside those WASM sandboxes, we had two serious candidates. This post documents what we found when we put them head to head under real load.
The Two Approaches
Option A: JavaScript inside WebAssembly (SpiderMonkey via jco)
The WebAssembly Component Model the emerging standard for composable, interoperable WASM modules ships with official JavaScript tooling: jco and componentize-js.
The idea is compelling: bundle the entire SpiderMonkey JS engine (the engine that powers Firefox) into a WASM component alongside your JavaScript source. The result is a single .wasm binary that is a fully self-contained JS runtime. Any WASM host a browser, Wasmtime, or your custom Rust sidecar can execute it without knowing or caring that there’s a JS engine inside.
This means you can write your business logic in plain JavaScript and deploy it as a WASM component. No recompilation, no language barrier.
Option B: Code Compiled Directly to WASM (TinyGo)
TinyGo is an alternative Go compiler built for constrained environments: microcontrollers, embedded systems, and WebAssembly. Unlike the standard Go toolchain, which produces large binaries with a heavy runtime, TinyGo compiles Go directly to compact WASM with a minimal runtime footprint.
The output is not a JS engine wrapped in WASM it is just the compiled program logic, represented as WebAssembly instructions. When Wasmtime executes it, there is no interpreter in the middle.
The Test Setup
Our architecture routes requests like this:
Client → Elixir (Phoenix) → Unix Domain Socket → Rust Sidecar → Wasmtime → WASM Component
The Elixir host handles the HTTP layer and passes execution requests to the Rust sidecar over a Unix Domain Socket (UDS). The sidecar maintains a pool of WASM instances and dispatches calls into them via Wasmtime.
To get clean numbers, we benchmarked a simple health endpoint deliberately trivial, so execution overhead is the only variable:
curl http://127.0.0.1:4000/o/<org_id>/w/<workspace_id>/apps/go-benchmark/health
# → ok
The Results
| Metric | JS (SpiderMonkey via jco) | Go (TinyGo) | Difference |
|---|---|---|---|
| Pure execution floor (Rust telemetry) | ~35 ms | ~1.3 ms | 26× faster |
| End-to-end fastest (Elixir → UDS → Wasm) | ~40 ms | ~2.78 ms | 14× faster |
| End-to-end average (c=10 concurrency) | ~45 ms | ~6.32 ms | 7× faster |
| Max throughput (c=1000) | ~150–200 req/s | ~1,559 req/s | ~8× higher |
| Cold boot penalty | ~0 ms (Wizer snapshot) | ~50 ms (runtime init) | JS wins |
| Memory at c=1000 | Gigabytes (1k isolates) | ~10 MB total | Go wins |
The SpiderMonkey approach wasn’t even close under load. But the reasons why are worth understanding in depth because they reveal something fundamental about how WASM security works.
Why JS Hits a Wall: The JIT Killer
Modern JavaScript engines V8, SpiderMonkey, JavaScriptCore owe their speed to Just-In-Time (JIT) compilation. As your code runs, the engine profiles hot code paths, compiles them down to native machine code on the fly, and replaces the interpreted path with the faster compiled version.
JIT is why JavaScript in a browser can run within a factor of 2× of native C code for CPU-bound workloads. It’s the entire reason JS can power complex applications at all.
Inside a WebAssembly component, JIT is dead.
The WASM Component Model enforces W^X memory Write XOR Execute. A page of memory may be writable or executable, never both simultaneously. This is a deliberate, correct security decision. Without W^X, a malicious WASM component could write shellcode into memory and execute it, escaping the sandbox entirely.
But JIT requires writing machine code into memory and then executing it. W^X makes that impossible. SpiderMonkey, trapped inside a WASM sandbox, falls back to its C++ interpreter the slow path it normally uses only for code that hasn’t been profiled yet.
The result is a hard execution floor of ~35 ms per call, regardless of how simple the logic is. That floor comes from SpiderMonkey interpreting your JavaScript through layers of C++, with no JIT escape hatch. At c=1,000, those 35 ms slots stack up instantly and saturate CPU cores.
Why Go Flies: Compilation All the Way Down
TinyGo takes a different approach. Your Go source compiles ahead-of-time to WebAssembly binary instructions. When Wasmtime executes the component, it runs those instructions directly there is no interpreter, no engine overhead, no extra abstraction layer.
Wasmtime itself does have an optional JIT tier (Cranelift) that compiles WASM instructions to native code. This is a different kind of JIT it operates at the WASM instruction boundary, not inside the component’s memory and it doesn’t violate W^X. The result is that Go logic executes in ~1.3 ms, essentially at native speed.
The memory story is equally dramatic. Each SpiderMonkey component instance bundles the entire JS engine in its linear memory. Running 1,000 concurrent isolates means 1,000 copies of SpiderMonkey which is why memory balloons into gigabytes. TinyGo components carry only the compiled application code and a minimal runtime. Sustaining 1,000 concurrent executions consumed roughly 10 MB over the sidecar’s idle baseline.
The One Area Where JS Wins: Cold Boot
This is not academic it’s a real operational advantage.
The jco toolchain uses Wizer, a WASM pre-initializer built by the Bytecode Alliance. During the build phase, Wizer runs the SpiderMonkey component through its entire initialization sequence engine startup, memory layout, JS parser initialization and then takes a snapshot of the linear memory at that exact point. That snapshot becomes the component’s starting state.
When Wasmtime’s pooling allocator checks out a JS instance from the pool, it copies the snapshot into place. From the pool’s perspective, SpiderMonkey is already fully initialized. Cold boot cost: ~0 ms.
TinyGo does not yet have Wizer integration in our pipeline. When Wasmtime allocates a fresh Go instance, the Go runtime must initialize itself from scratch: the memory allocator warms up, the garbage collector registers its state, goroutine scheduling starts. This is quick by Go standards typically ~47–58 ms but it’s a real spike that occasionally appears in tail latency.
The mitigation is Wasmtime’s pooling allocator. Rather than destroying and recreating instances after each request, the pooling allocator keeps a fixed pool of “warm” instances that have already paid the initialization tax. After the first run, subsequent requests route through the 1.3 ms fast path without triggering re-initialization. The 50 ms spike only reappears when the pool needs to expand or a garbage collection cycle forces a re-init.
In practice, under steady load, the vast majority of requests never see the cold boot cost.
The Verdict
TinyGo is the right choice for this architecture. The 26× raw execution improvement and 8× throughput gain are not marginal optimizations they change what the platform can do. A JS-based system starts struggling above ~200 req/s; the TinyGo system handles over 1,500 req/s with memory overhead that barely registers.
The cold boot story is a real (but solvable) problem. The next step is integrating Wizer into the TinyGo compilation pipeline capturing a memory snapshot of the initialized Go runtime the same way jco does for SpiderMonkey. If that works, we get Go’s raw execution speed and JS’s zero cold-boot story. Best of both worlds, none of the trade-offs.
The broader lesson: when you’re sandboxing code in WASM, the abstraction layers between your logic and the metal dominate your performance profile more than any other factor. A compiled language with a thin runtime will consistently outperform an interpreted language bundled with its full engine because WASM’s security model specifically dismantles the mechanism that makes interpreted languages competitive.