Profiling is the part of performance work that stops you optimising the wrong thing. Go has unusually good tooling here: pprof is in the standard library, it costs almost nothing to enable, and it answers the only question that matters — where does the time actually go.
Compiling to WebAssembly normally takes that away. The Wasm module is a sandboxed binary running inside a host runtime, and the profiling hooks the toolchain relies on are not there. You get portability and lose observability, which is a bad trade to discover late.
wzprof closes that gap.
What makes Wasm hard to profile
A WebAssembly module is portable bytecode executing in a stack-based virtual machine, deliberately isolated from its host. The sandbox is the entire point — it is what makes running untrusted code reasonable — but isolation cuts both ways. The usual profiling mechanisms depend on access the sandbox exists to deny.
Go compiles to Wasm with GOOS=wasip1 GOARCH=wasm, and the resulting module runs anywhere with a WASI runtime: servers, edge workers, embedded hosts. Very useful, and until recently effectively opaque once running.
The approach wzprof takes
The trick is to instrument the runtime rather than the guest.
wzprof is built on Wazero (opens in a new tab), a WebAssembly runtime written in Go with no CGo dependency. Wazero exposes a function listener API — a hook that fires on every guest function call. wzprof attaches to it, records stack traces and timings, and writes them out in pprof's format.
Two consequences follow, and both are more interesting than they first appear.
It works regardless of source language. The instrumentation sits at the bytecode level, so a module compiled from Rust, C, or Zig profiles exactly like one compiled from Go. You are not depending on any language's runtime cooperating.
The output is ordinary pprof. Everything already built for pprof works unchanged — go tool pprof, flame graphs, and continuous profiling systems like Pyroscope or Parca.
Generating a profile
Take something minimal:
package main
import "fmt"
func main() {
fmt.Println("hello, wzprof")
}Build it for WASI:
GOOS=wasip1 GOARCH=wasm go build -o main.wasm main.goThen run it under wzprof, once for each profile type:
wzprof -sample 1 -cpuprofile ./cpuprofile.out ./main.wasm
wzprof -sample 1 -memprofile ./memprofile.out ./main.wasm-sample 1 sets the sampling rate. Sampling everything is fine for a short program and expensive for a real one — this is the knob you turn when the instrumentation overhead starts distorting what you are measuring.
Open the result with the standard tool:
go tool pprof -http :4001 memprofile.outReading it
The CPU profile in the top view:
And allocations as a flame graph:
NOTE
Look at what these are actually showing. The CPU profile is 99.63% runtime.memequal; the allocation graph is 27.69 kB, essentially all of it runtime.schedinit, os.init, and friends.
That is not a finding — it is what you get when you profile a program whose entire body is one Println. Startup dominates because there is nothing else to measure.
I have left these in because they are honest about what the tool produces on a trivial input, and because recognising "this profile is all runtime startup" is a genuinely useful skill. On a real workload the guest's own functions appear and the runtime frames recede into the background. If they do not, your program is not doing enough work to be worth profiling.
Where it is useful
The obvious case is a Go service compiled to Wasm for edge deployment, where you want the profiling story you would have had natively.
The more interesting case is the cross-language one. If you are running plugins compiled from several languages inside one host, wzprof gives you a single profiler across all of them — where otherwise you would need each language's own tooling, assuming it survives the Wasm target at all.
That is the real argument for instrumenting at the runtime layer. Language-specific profilers give richer detail; a runtime-level profiler gives you coverage, and coverage is what you lack when the module could have come from anywhere.
Reference
- wzprof (opens in a new tab)
- Wazero (opens in a new tab)
- Go WebAssembly documentation (opens in a new tab)
This tooling is young, and the ecosystem around WASI is still moving. But the direction is right: portability that costs you observability is not portability you can run in production, and closing that gap at the runtime layer is the approach most likely to generalise.