Runtime
Wasm Performance: Profiling Golang Builds and Reading Benchmark Results
Compiling an existing service to WebAssembly is the easy half. The hard half arrives when it runs slower than the native build and every webassembly profiling tool you reach for returns an empty flame graph.

A service compiles to WebAssembly on the second attempt. It passes its tests. It runs, and it runs about two and a half times slower than the binary it replaced.
This is the point where the ordinary loop (profile, find the hot spot, fix the hot spot) stops working, because the profiler comes back with nothing. Not a wrong answer. An empty one. Every wasm performance investigation starts here, with a benchmark that says the port is slower and no profile that says where.
Where wasm performance profiling goes blind
A sampling profiler is a simple idea executed carefully. Set a timer, and on each tick interrupt the thread and walk the stack, recording which function was executing and who called it. Do that a few hundred times a second and the distribution of samples is the distribution of time.
Both halves of that break inside a module.
The interrupt half needs signal delivery. SIGPROF is what Go’s CPU profiler arms, the same
runtime whose opaque stack keeps goroutines from being serialised
and what perf
relies on to sample a process; inside a WebAssembly guest there is generally no such mechanism,
because the guest does not own a thread in the sense the operating system means. The stack-walking
half needs a native frame chain: a linked list of saved frame pointers the walker can follow,
the same structure CPython walks to print a traceback.
WebAssembly’s call stack is the host runtime’s own structure and is not required to look like
anything a native walker recognises.
Which is why a Wasm benchmark is usually easy to produce and a Wasm profile is not.
So Go’s pprof compiled to wasip1 will happily give you heap, goroutine, block and mutex
profiles, because those are counters the Go runtime maintains in its own code and that code runs
fine inside the module. CPU profiling returns an empty profile. That asymmetry is worth internalising
before spending an afternoon on it: for Golang builds targeting Wasm, allocation problems are still
findable and hot loops are not.
What Golang builds can measure instead
Three approaches, in ascending order of how much they distort what they measure.
Boundary accounting. Before touching the module at all, count the crossings. Every call from host to guest and back has a fixed cost, and every argument that is not a scalar gets copied into linear memory. Instrument the host side, which needs no special support because the host is ordinary native code, and you get a table of how often each import and export is called. A chatty interface shows up immediately here and is the most common cause of a bad wasm performance number. This is also the cheapest form of webassembly profiling available, because none of it runs inside the guest.
Runtime-level counters. Wasmtime, WAMR and the browser engines each expose some amount of their own accounting: compilation time, instantiation time, memory growth events, sometimes per-function entry counts. This tells you nothing about the shape of the hot loop but a great deal about whether the problem is in the loop at all. Modules that spend most of their wall clock being compiled are not rare, especially in short-lived invocations.
Compiled-in instrumentation. The reliable option, and the intrusive one. Compile the module with function-level entry and exit hooks and have it accumulate counts and cycle deltas into a table it exports. That is real webassembly profiling with real attribution (you get a call graph), and the overhead is not small and is not uniform. Small hot functions are penalised hardest, which is precisely where the interesting ones live, so the profile flatters large functions.
Golang performance profiling has historically leaned on pprof for everything, so this is the step
teams resist longest. The third option is where most serious work on this target ends up, and the
honest framing is that it is a different measurement, not the same measurement through a
different lens. It answers “which functions are called how often, in what pattern” very well and
“how many cycles does this loop take” only approximately.
A benchmark that is worth comparing
Most published Wasm numbers are not comparable, for a small number of avoidable reasons. If you are producing them:
| Report | Why it changes the number |
|---|---|
| Runtime and exact version | Cranelift’s output changes between releases; AOT and JIT differ substantially |
| AOT or JIT, and whether compile time is included | Short invocations are often dominated by compilation |
| SIMD enabled or not | A vectorised inner loop compiled without SIMD support can be several times slower |
| Whether the boundary is in the measured region | Excluding it measures the module; including it measures the system |
| Memory: initial and maximum pages | Growth events are not free and a low initial size hides as latency |
Without those five, a wasm performance claim is a number attached to an unnamed configuration.
Aside on the thing that is actually usually wrong
Anecdotally — and this is anecdote, not measurement — the most common root cause is neither the instruction stream nor the runtime. It is a data model that was fine when a function call was a jump and is expensive when a function call is a copy. Code that passed a struct by reference and now serialises it, per call, in a loop. The fix is an interface change, not an optimisation, which is unwelcome news at the point where someone has already committed to the port. Whether that means Wasm is the wrong target or the interface was always too chatty is a genuinely open question and it depends on the codebase.
The practical order
Count boundary crossings first. Check compile-versus-execute split second. Reach for golang performance profiling inside the guest third, and only third. Only then reach for instrumentation, because it is the expensive step in both engineering time and measurement distortion.
And take the empty CPU profile as information rather than a broken tool. It is telling you, correctly, that the mechanism it depends on is not present. The mistake is reading it as “no time is being spent here”.