Compiling Zig to WebAssembly
This guide answers one task: build a WebAssembly module in Zig that is as small as the language allows, exports a usable interface, and manages its own memory explicitly — with no runtime and no hidden allocations.
Prerequisites
- [ ] Zig 0.13 or later.
- [ ] A task suited to a compiled module: computation over bytes or numbers.
- [ ] Comfort with explicit allocation; Zig has no global allocator by design.
- [ ] A local server to load the module from.
Two targets, and which to use
Zig offers wasm32-freestanding and wasm32-wasi. The first produces a module with no operating system
assumptions at all — no files, no environment, no standard input — and is what a browser module wants.
The second targets a WASI host and is what you build for a server runtime.
# browser: freestanding, no libc, smallest possible
zig build-lib src/main.zig -target wasm32-freestanding -dynamic -rdynamic \
-O ReleaseSmall -femit-bin=dist/engine.wasm
# server: WASI
zig build-exe src/main.zig -target wasm32-wasi -O ReleaseSmall
-dynamic -rdynamic produce a module with exports rather than an executable expecting a main. Building
without them gives a module the browser cannot call into, which is the first thing to check when
instance.exports is empty.
A module with a real interface
Zig’s export syntax is a keyword, and the calling convention for WebAssembly is the default.
const std = @import("std");
// a fixed arena, sized at compile time — no allocator required
var buffer: [1 << 20]u8 = undefined;
export fn buffer_ptr() [*]u8 {
return &buffer;
}
export fn buffer_len() usize {
return buffer.len;
}
export fn sum_u32(ptr: [*]const u32, len: usize) u32 {
var total: u32 = 0;
for (ptr[0..len]) |v| total +%= v; // wrapping add, explicit
return total;
}
export fn scale_f32(ptr: [*]f32, len: usize, factor: f32) void {
for (ptr[0..len]) |*v| v.* *= factor;
}
Two Zig habits show here and both are deliberate. +%= is wrapping addition; plain += would trap on
overflow in a safe build, which is often what you want and must be chosen rather than assumed. And
slicing a many-item pointer with ptr[0..len] produces a bounds-checked slice in debug builds and a
plain pointer in ReleaseFast — the safety is a build mode, not a language default.
Allocation, explicitly
Zig has no global allocator. Any code that allocates takes one as a parameter, which in a WebAssembly module means deciding where memory comes from.
var heap_buf: [4 << 20]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&heap_buf);
const allocator = fba.allocator();
export fn process(in_ptr: [*]const u8, in_len: usize) usize {
fba.reset(); // arena semantics: free everything at once
const out = transform(allocator, in_ptr[0..in_len]) catch return 0;
@memcpy(buffer[0..out.len], out);
return out.len;
}
A FixedBufferAllocator reset per call is the arena pattern in its simplest form: allocation is a pointer
bump, freeing is a single reset, and there is no fragmentation and no collector. For a module that
processes one request at a time this is both the fastest and the smallest option.
Where a general-purpose allocator is genuinely needed, std.heap.WasmAllocator uses memory.grow
directly and is the right choice — with the usual consequence that growth detaches any typed-array views
JavaScript is holding.
Loading it
A freestanding Zig module has no glue: instantiate it and call the exports.
const { instance } = await WebAssembly.instantiateStreaming(fetch('/dist/engine.wasm'), {
env: {
host_log: (ptr, len) => {
const bytes = new Uint8Array(instance.exports.memory.buffer, ptr, len);
console.log(new TextDecoder().decode(bytes));
},
},
});
const { memory, buffer_ptr, sum_u32 } = instance.exports;
const ptr = buffer_ptr();
const view = new Uint32Array(memory.buffer, ptr, 1000);
view.set(data);
console.log(sum_u32(ptr, 1000));
Declaring an import in Zig is an extern function, and the module name is env by default:
extern "env" fn host_log(ptr: [*]const u8, len: usize) void;
Using the build system
Command lines are fine for a single file and unpleasant once there are several targets. Zig’s build system is a Zig program, which means the build configuration is ordinary code rather than a declarative format with its own semantics.
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
const lib = b.addSharedLibrary(.{
.name = "engine",
.root_source_file = b.path("src/main.zig"),
.target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .freestanding,
}),
.optimize = optimize,
});
lib.rdynamic = true; // keep exports
lib.entry = .disabled; // no _start
b.installArtifact(lib);
const wasi = b.addExecutable(.{
.name = "engine-cli",
.root_source_file = b.path("src/cli.zig"),
.target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .wasi }),
.optimize = optimize,
});
b.installArtifact(wasi);
}
zig build -Doptimize=ReleaseSmall
ls zig-out/lib/engine.wasm zig-out/bin/engine-cli.wasm
Defining both targets in one build file is the arrangement that pays off: the same core logic compiles for the browser and for a standalone runtime, and the CLI build gives you a way to run and test the module outside a browser with ordinary tooling. Testing a WebAssembly module through a native or WASI build is dramatically faster to iterate on than driving it through a headless browser, and for pure logic it exercises the same code.
Add a test step while you are there — zig build test running the same sources natively catches logic
errors in seconds rather than in a browser test run.
Expected output
zig build-lib src/main.zig -target wasm32-freestanding -dynamic -rdynamic \
-O ReleaseSmall -femit-bin=dist/engine.wasm
ls -l dist/engine.wasm
# 9_216 dist/engine.wasm
brotli -q 11 -c dist/engine.wasm | wc -c
# 4_812
console:
sum_u32(1000 values) = 499500 in 0.004 ms
scale_f32(1e6 values, 2.0) in 1.6 ms
Under five kilobytes compressed for a module doing real work is the headline number, and it is why Zig appears in size comparisons at the bottom of the table.
Build modes and what they trade
Zig’s four build modes differ more than most languages’ optimisation levels, because they change semantics rather than only code generation.
Debug includes bounds checks, overflow checks, and safety panics with useful messages. ReleaseSafe
keeps the checks and optimises — a genuinely useful combination that most languages do not offer.
ReleaseFast removes the checks for speed. ReleaseSmall removes them and optimises for size, which is
usually the right choice for a browser module.
zig build-lib … -O Debug # 60 kB, checks everything
zig build-lib … -O ReleaseSafe # 14 kB, checks kept
zig build-lib … -O ReleaseSmall # 9 kB, no checks
Shipping ReleaseSafe is worth considering for a module handling untrusted input: a safety panic becomes
a WebAssembly trap, which JavaScript catches as an exception, and that is far better behaviour than
reading past the end of a buffer.
Gotchas
- Missing
-dynamic -rdynamic. No exports;instance.exportsis empty. - Overflow trapping unexpectedly. Plain arithmetic traps in safe modes. Use the wrapping operators where wrapping is intended.
- Assuming an allocator exists. Every allocating function takes one; decide where it comes from.
WasmAllocatorgrowing memory mid-call. Detaches JavaScript’s views; prefer a fixed arena.- Standard library functions that assume an OS. Freestanding has no files, no time and no stdout.
- Language churn between versions. Zig is pre-1.0 and its standard library changes; pin the compiler version in CI.
Performance note
Scaling a million f32 values took 1.6 ms in a 4.8 kB module — effectively identical to the equivalent
Rust and C implementations, which is expected since all three compile through LLVM to the same
instructions. The difference is in the artifact: Zig’s freestanding output carries no runtime support at
all, which is why it is consistently the smallest of the three for equivalent work.
Frequently Asked Questions
Should I choose Zig over Rust for browser modules? For the smallest possible artifact and a C-like mental model, yes. For ecosystem, generated bindings and a stable language, Rust is the safer choice. Many teams use Zig for small self-contained kernels and Rust for anything needing libraries.
How do I handle strings?
As pointer and length pairs, encoded as UTF-8 bytes, exactly as in C. Zig slices carry a length so the
module side is comfortable; the JavaScript side encodes with TextEncoder into the module’s buffer and
decodes results with TextDecoder. There is no automatic conversion and none is wanted at this size.
Can Zig compile my existing C code?
Yes — zig cc is a drop-in C compiler with cross-compilation built in, and it targets WebAssembly
directly. That makes Zig useful as a build tool even in projects that contain no Zig.
Is the language stable enough to depend on? The language is pre-1.0 and changes between releases, mostly in the standard library. Pin the version, budget an afternoon per upgrade, and keep the module’s surface small so the churn is contained.
Related
- Comparing payload size across languages — Zig against everything else.
- Implementing a bump allocator in Wasm — the arena pattern in general.
- Building C code with the WASI SDK — the other route for C-family code.
One practical closing note: keep the exported surface deliberately small. Zig makes it easy to export a great deal, and every export is an interface you have to keep working — the discipline that keeps a small module small is refusing to grow its contract.
← Back to Other Languages in the Browser