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.

Two targets, two assumptions The freestanding target assumes no operating system and produces the smallest modules, suitable for a browser. The WASI target assumes a host providing files, clocks and environment, suitable for a standalone runtime. wasm32-freestanding no libc, no syscalls, no assumptions imports only what you declare 2–10 kB for real work use this in a browser wasm32-wasi files, clocks, environment, args standard library mostly works larger, and portable to runtimes use this on a server

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;
No glue in the middle A freestanding module declares its imports and exports directly, and JavaScript instantiates it and calls them. There is no generated binding layer, which keeps the artifact small and the interface explicit. JavaScript supplies env imports calls exports directly imports exports engine.wasm no runtime, no libc fixed arena in the data segment 4.8 kB compressed nothing generated, nothing to keep in sync

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.

The shortest toolchain there is Zig targets WebAssembly without an external toolchain, sysroot or binding generator. The compiler emits the binary, and exported functions are callable directly. build.zig target set in one line zig build no external toolchain freestanding binary no runtime shipped called from JS plain numeric exports Nothing is generated for you: strings and structs cross as pointers and lengths you marshal yourself. That is the trade — the smallest binaries available, and the most glue to write by hand. Allocators are explicit, which makes it unusually clear who owns every byte the module holds.

Gotchas

  • Missing -dynamic -rdynamic. No exports; instance.exports is 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.
  • WasmAllocator growing 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.

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