WebAssembly with Zig 0.16.0: A Complete Tutorial and Reference
Target audience: Systems programmers comfortable with C-level memory management who want a rigorous, no-shortcuts guide to compiling Zig to WebAssembly. Every code example in this post is written for Zig 0.16.0 (released April 2026) and will not compile correctly on earlier versions.
Table of Contents
- Why Zig for WebAssembly?
- The WebAssembly Binary Format — What You're Actually Building
- Installing and Verifying Zig 0.16.0
- Target Selection: freestanding vs. wasi
- The build.zig — Every Line Explained
- What Zig 0.16.0 Changed That Affects WASM
- Zig Types at the WASM ABI Boundary
- Exporting Functions
- The std.Io Situation in Freestanding WASM
- The Linear Memory Model
- Memory Allocators Available in Freestanding WASM
- Importing JavaScript Functions
- Passing Complex Data Across the Boundary
- Formatting Strings in WASM Memory
- Project 1: Arithmetic & Bit Operations Library
- Project 2: String Operations Module
- Project 3: Sorting and Statistics Engine
- Project 4: Image Pixel Processing Pipeline
- Project 5: Conway's Game of Life
- Build Optimization — Size, Speed, and wasm-opt
- Debugging WASM Modules
- WASI: Running Zig WASM Outside the Browser
- Advanced: SIMD with @Vector
- Common Pitfalls and How to Fix Them
- What Comes Next
1. Why Zig for WebAssembly?
WebAssembly is often paired with Rust (via wasm-pack), Emscripten'd C++, or Go. All three bring substantial baggage: wasm-pack generates a JS glue file and pulls in a Rust standard library; Emscripten ships a complete POSIX emulation layer including malloc, printf, and a virtual filesystem; Go embeds a full garbage collector and goroutine scheduler into every binary. The resulting .wasm files for "Hello World" range from 50 KB to 2 MB before compression.
Zig takes a different posture. It has no garbage collector, no hidden allocator, no exception unwind tables, and no required runtime. The wasm32-freestanding target in Zig 0.16.0 produces modules that contain precisely what you wrote — nothing else. A math library of twenty exported functions typically compiles to under 600 bytes with ReleaseSmall.
Beyond binary size, Zig's design choices align unusually well with what WASM needs:
No implicit control flow. WASM has no exceptions, no signals, no setjmp/longjmp. Zig's error union system models fallibility through explicit return values, which maps cleanly to the WASM stack machine without needing any out-of-band mechanism.
comptime eliminates abstraction cost. Generic data structures in Zig — sorted arrays, fixed-size queues, type-checked memory layouts — are resolved entirely at compile time. The resulting WASM is as tight as hand-written code.
First-class cross-compilation. zig build -Dtarget=wasm32-freestanding is the entire cross-compilation story. No sysroot, no toolchain file, no special SDK.
Explicit allocators. Every allocation in Zig goes through an explicitly-passed Allocator. In WASM, where you own memory management completely, this means you know exactly which bytes are yours.
export and extern are first-class language features. The WASM ABI boundary (what you expose to JS, what you import from JS) is expressed directly in Zig syntax with no annotation macros or code-generation step.
That said, Zig's WASM support does require you to understand the ABI boundary yourself. There is no equivalent of Rust's #[wasm_bindgen] that automatically marshals strings, arrays, or closures. This post teaches you to do that marshalling by hand — which means you will understand exactly what your module does at the wire level.
2. The WebAssembly Binary Format
Before writing a line of Zig, you need a mental model of what a .wasm file actually is. WASM is a binary format for a stack-based virtual machine. Every .wasm file is a module structured as a sequence of typed sections.
Sections in a WASM Module
| Section | Purpose |
|---|---|
| Type | Declares function signatures (parameter and return types) |
| Import | Lists functions, memories, tables, and globals imported from the host |
| Function | Maps function indices to type indices |
| Table | Declares function tables (used for indirect calls) |
| Memory | Declares the linear memory (size in 64 KB pages) |
| Global | Declares global mutable/immutable variables |
| Export | Names the functions, memories, and globals visible to the host |
| Start | Optional: a function to run immediately after instantiation |
| Code | Contains the actual function bodies (instructions) |
| Data | Initializes ranges of linear memory with constant bytes |
| Custom | Arbitrary data — DWARF debug info lives here |
When Zig compiles export fn add(a: i32, b: i32) i32 { return a + b; } to WASM, the Type section gets a signature (i32, i32) -> i32, the Function section maps that signature to a function index, the Code section contains the two local.get instructions and one i32.add, and the Export section names the entry "add". That is the entire module for this function — under 30 bytes binary, around 60 bytes text format.
WASM Value Types
WASM has exactly four value types at its core:
| WASM Type | Zig Equivalents | Width |
|---|---|---|
i32 |
i32, u32, bool, *T (pointers as offsets), usize on wasm32 |
32-bit |
i64 |
i64, u64 |
64-bit |
f32 |
f32 |
32-bit |
f64 |
f64 |
64-bit |
Everything larger than these (structs, slices, strings, arrays) must be passed through linear memory — the flat byte array shared between WASM and JavaScript. Functions can only pass and return these four types directly. This constraint defines the entire discipline of WASM ABI programming.
Linear Memory
Linear memory is a contiguous, mutable byte array that starts at address 0. It grows in 64 KB increments called pages. The minimum declaration is 1 page (65,536 bytes). Zig's WASM linker allocates your global variables and stack in the first few pages; heap allocations (if any) come after. Both Zig code and JavaScript can read and write the same memory by treating it as a Uint8Array, Int32Array, Float32Array, or any other typed array view.
Text Format (WAT)
To inspect what Zig actually compiled, install the wabt toolchain (npm install -g wabt) and run:
wasm2wat zig-out/bin/app.wasm -o app.wat
cat app.wat
Reading the WAT output is essential when debugging unexpected behavior or checking that your exports have the right signatures.
3. Installing and Verifying Zig 0.16.0
Download the prebuilt binary for your platform from ziglang.org/download. Extract it, add the directory to your PATH, and confirm:
zig version
# Should print: 0.16.0
Supporting tools used in this post:
# Static server for testing in a browser
npm install -g serve
# WASM toolkit: wasm2wat, wat2wasm, wasm-opt, wasm-strip
npm install -g wabt
npm install -g binaryen
Confirming WASM target availability
zig targets | grep wasm
You should see wasm32 and wasm64 listed under architectures, and freestanding, wasi, and emscripten under OS options.
4. Target Selection
Zig exposes two production-ready WASM targets in 0.16.0:
wasm32-freestanding
No operating system. No POSIX interface. No filesystem, no network, no threads (without explicit feature flags). This is the target for browser WASM. You are responsible for everything. Linear memory starts at whatever the linker places it; JavaScript is the "operating system" that provides any external services you need.
Zig's standard library compiles for this target but many modules (anything in std.Io, std.fs, std.net) will fail to compile or will pull in unimplementable dependencies. The usable stdlib subset is: std.math, std.mem, std.fmt.bufPrint, std.sort, std.crypto, std.unicode, std.json (partially), and the built-in types and builtins.
wasm32-wasi
WASI (WebAssembly System Interface) is a standardized set of imports that a WASM host (Wasmtime, Cloudflare Workers, Node.js via @bytecodealliance/wasmtime-js, browsers with WASI shims) provides. It covers: file I/O, environment variables, clock access, random bytes, and command-line arguments. The Zig standard library is Tier-2 supported for wasm32-wasi in 0.16.0, meaning most of std works correctly.
Use wasm32-freestanding for browser modules. Use wasm32-wasi for server-side or CLI tools that run in WASM runtimes.
Target query in build.zig
// For browser WASM:
const target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .freestanding,
});
// For WASI:
const target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .wasi,
});
// With SIMD and bulk-memory (for optimized pixel ops, etc.):
const target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .freestanding,
.cpu_features_add = std.Target.wasm.featureSet(&.{
.simd128,
.bulk_memory,
.nontrapping_fptoint,
.sign_ext,
}),
});
The cpu_features_add field takes a feature set built from std.Target.wasm.featureSet. Common useful features:
| Feature | What it enables |
|---|---|
simd128 |
128-bit SIMD operations (@Vector types) |
bulk_memory |
Fast memory.copy and memory.fill instructions |
nontrapping_fptoint |
Float-to-int conversions that saturate instead of trapping |
sign_ext |
Integer sign-extension instructions |
atomics |
Atomic operations (needed for thread safety) |
mutable_globals |
Mutable imported globals |
Browser support note: simd128, bulk_memory, nontrapping_fptoint, and sign_ext are supported in all evergreen browsers as of 2025. atomics requires SharedArrayBuffer and specific HTTP headers (Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: require-corp).
5. The build.zig — Every Line Explained
This is the canonical build.zig for a browser WASM module in Zig 0.16.0. Read every comment.
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
// --- Target ---
// b.resolveTargetQuery converts a cross-compilation spec into a
// fully-resolved target descriptor that the build graph can use.
// We hard-code wasm32-freestanding here because this file exists
// solely to produce a browser WASM module.
const target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .freestanding,
});
// --- Optimize mode ---
// b.standardOptimizeOption() reads -Doptimize=<mode> from the
// command line, defaulting to Debug when not specified.
// For WASM you almost always want ReleaseSmall in production.
const optimize = b.standardOptimizeOption(.{});
// --- Module ---
// In Zig 0.16.0, addExecutable requires a pre-built Module via
// b.createModule(). The old flat-field API (passing root_source_file
// directly to addExecutable) was removed in 0.14.0 and is gone.
const wasm_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// --- Executable ---
// Despite the name "Executable", this also produces .wasm libraries.
// A WASM "library" with exported functions is still technically an
// executable from the linker's perspective.
const exe = b.addExecutable(.{
.name = "app",
.root_module = wasm_module,
});
// --- Entry point ---
// Disable the default _start entry point. The module is driven
// entirely from JavaScript via exported functions.
// Without this, the linker expects a pub fn main() and will error.
exe.entry = .disabled;
// --- Dynamic exports ---
// rdynamic keeps all symbols marked `export` in the output even if
// the linker's dead-code elimination would otherwise remove them.
// Without this, exported functions that are never called internally
// may be silently stripped.
exe.rdynamic = true;
// --- Install ---
// Copies the final .wasm into zig-out/bin/.
b.installArtifact(exe);
// --- Optional: custom install path ---
// If you want the .wasm file directly in your web root:
// const install = b.addInstallFile(exe.getEmittedBin(), "../www/app.wasm");
// b.getInstallStep().dependOn(&install.step);
}
Variations
If you need to target WASI instead:
// For WASI, remove exe.entry = .disabled and exe.rdynamic = true.
// The _start function (generated from pub fn main) IS the entry point.
const exe = b.addExecutable(.{
.name = "app",
.root_module = wasm_module,
});
b.installArtifact(exe);
If you want to specify exports at the build level instead of using export fn:
// Useful when you want to control exports from build.zig without
// modifying source files (e.g. for library targets with many entry points).
wasm_module.export_symbol_names = &.{
"add",
"multiply",
"init_buffer",
"process_pixels",
};
When using export_symbol_names, you do NOT use the export keyword in your Zig source — the build system handles visibility.
Adding a post-build wasm-opt pass:
const wasm_opt = b.addSystemCommand(&.{
"wasm-opt",
"-Oz", // optimize for size
b.fmt("{s}/bin/app.wasm", .{b.install_path}), // input
"-o",
b.fmt("{s}/bin/app.wasm", .{b.install_path}), // output (in-place)
});
wasm_opt.step.dependOn(b.getInstallStep());
const opt_step = b.step("opt", "Run wasm-opt after build");
opt_step.dependOn(&wasm_opt.step);
Run with: zig build -Doptimize=ReleaseSmall && zig build opt
6. Zig 0.16.0 Changes Affecting WASM
Zig 0.16.0 (released April 18, 2026) contains several changes that directly affect WASM development. If you've seen tutorials or blog posts written for 0.13.x or 0.14.x, these are the differences you need to know.
@Type is removed — use @Int, @Struct, @Enum, etc.
The @Type built-in is gone in 0.16.0. Proposal #10710, long accepted and now implemented, replaces it with specific type-creating builtins. If you're writing any WASM code that uses comptime metaprogramming, update:
// OLD (0.14.0 and earlier):
const U8 = @Type(.{ .int = .{ .signedness = .unsigned, .bits = 8 } });
// NEW (0.16.0+):
const U8 = @Int(.unsigned, 8);
The full list of replacements: @Int, @Struct, @Union, @Enum, @Pointer, @Fn, @Tuple, @EnumLiteral.
@cImport is deprecated
For WASM projects this rarely matters (you're not calling C APIs in a freestanding context), but if you use @cImport in shared Zig code that you also compile to WASM, move the C header translation to the build system:
// build.zig
const translate_c = b.addTranslateC(.{
.root_source_file = b.path("src/c.h"),
.target = target,
.optimize = optimize,
});
build.zig API: root_module is now mandatory
As noted in Section 5, addExecutable requires root_module = b.createModule(...). The deprecated flat-field API is fully removed. All existing tutorials that pass root_source_file, target, optimize directly to addExecutable need updating.
std.Io replaces the old std.io system
The entire I/O subsystem was redesigned. std.io.Writer, std.io.Reader, std.io.FixedBufferStream, std.io.GenericReader, and std.io.AnyReader are gone. The replacements live in std.Io (capital I). This has major implications for freestanding WASM — see Section 9.
std.mem: indexOf* renamed to find*
As part of a convention unification effort merged in September 2025, the search functions in std.mem were renamed:
| 0.15.x name | 0.16.0 name |
|---|---|
std.mem.indexOf |
std.mem.find |
std.mem.indexOfScalar |
std.mem.findScalar |
std.mem.indexOfPos |
std.mem.findPos |
std.mem.lastIndexOf |
std.mem.findLast |
std.mem.lastIndexOfScalar |
std.mem.findLastScalar |
std.mem.indexOfAny |
std.mem.findAny |
New cut family functions were also added: std.mem.cut, std.mem.cutPrefix, std.mem.cutSuffix, std.mem.cutScalar, std.mem.cutLast, std.mem.cutLastScalar. These split slices around the first (or last) occurrence of a delimiter and return both halves.
Small integers coerce to floats
In 0.16.0, integers smaller than the float's mantissa bits (e.g., u8 → f32, i16 → f64) coerce implicitly. This is a minor quality-of-life improvement most relevant in pixel math where you constantly convert between byte values and float math.
WebAssembly Backend improvements
The self-hosted WebAssembly backend (not LLVM) received multiple bug fixes in 0.16.0. For production use, the LLVM backend (default) is still recommended for correctness on all features, but the self-hosted backend is rapidly maturing.
7. Zig Types at the WASM ABI Boundary
Understanding this section is the difference between WASM code that works and WASM code that produces silent garbage.
The four scalar types
Only these Zig types can appear as function parameters or return values in exported WASM functions:
export fn scalars(
a: i32, // signed 32-bit int
b: u32, // WASM sees this as i32; bit pattern is identical
c: i64,
d: u64, // WASM sees this as i64
e: f32,
f: f64,
g: bool, // coerces to i32, value 0 or 1
h: usize, // on wasm32, usize is u32, so i32
) i32 { ... }
Important: WASM has no unsigned integer type. u32 and i32 are both represented as i32 in the binary. The bit pattern is the same; the interpretation (signed vs. unsigned) happens in the application code, not the VM.
Pointers as i32
In wasm32-freestanding, pointers are 32-bit offsets into linear memory. A *T or [*]T becomes an i32 (WASM address) at the boundary:
// This exported function receives a WASM pointer and a length.
// JavaScript passes integer offsets, not native pointers.
export fn sum_array(ptr: [*]const f64, len: u32) f64 {
var total: f64 = 0;
for (ptr[0..len]) |v| total += v;
return total;
}
JavaScript side:
// Write 10 doubles into WASM memory at offset 1024,
// then call the exported function with that offset.
const memory = instance.exports.memory;
const view = new Float64Array(memory.buffer, 1024, 10);
for (let i = 0; i < 10; i++) view[i] = i * 1.5;
const total = instance.exports.sum_array(1024, 10);
console.log(total); // 67.5
Structs cannot cross the boundary
There is no ABI for passing structs directly between WASM and JavaScript. This:
// THIS WILL NOT WORK as an exported function.
const Vec2 = struct { x: f32, y: f32 };
export fn make_vec(x: f32, y: f32) Vec2 { ... } // Compile error: invalid return type for export
The correct pattern is to write the struct into a static buffer in WASM memory and return a pointer to it, as shown in Section 13.
The usize detail
On wasm32, usize is 32 bits (4 bytes), matching the address space. On wasm64 (experimental), it would be 64 bits. Always use usize for counts and offsets in WASM code — not u32 — so the code works across both targets without modification.
8. Exporting Functions
The export keyword
export makes a function visible in the WASM export table using the function's exact Zig name:
// src/main.zig
// Called as instance.exports.add(3, 4) from JavaScript.
export fn add(a: i32, b: i32) i32 {
return a + b;
}
// Underscores and numbers are fine; any valid identifier works.
export fn vec2_dot(ax: f32, ay: f32, bx: f32, by: f32) f32 {
return ax * bx + ay * by;
}
// Return type void becomes a WASM function that returns nothing.
export fn set_pixel(x: u32, y: u32, r: u8, g: u8, b_: u8) void {
// ...
}
Name visibility rules
The export keyword instructs Zig to emit the function with a public linkage name equal to the Zig identifier. Because export implies pub and places the function in the WASM export section, you do not also need pub. In fact, an export fn without pub is equivalent — the export table visibility overrides Zig's module visibility system.
Preventing dead-code elimination
Zig's linker performs dead-code elimination by default. An exported function that is never called internally would be removed were it not for two mechanisms:
exe.rdynamic = truein build.zig (the approach used throughout this post)- Listing the function in
wasm_module.export_symbol_names
If you add a function and it doesn't appear in the browser's instance.exports, a missing rdynamic or missing entry in export_symbol_names is almost always the cause.
Verifying exports
wasm2wat zig-out/bin/app.wasm | grep '(export'
# (export "memory" (memory 0))
# (export "add" (func $add))
# (export "sum_array" (func $sum_array))
9. The std.Io Situation in Freestanding WASM
This is the most important 0.16.0-specific section. It is the first thing that will bite you when migrating from 0.15.x.
What happened
Zig 0.16.0 introduced a completely redesigned I/O subsystem (std.Io). Unlike the old std.io which was a collection of loose functions and types, std.Io is based on a concrete interface type std.Io that must be provided to everything that does I/O. The default threading implementation (std.Io.Threaded) uses POSIX syscalls (getrandom, IOV_MAX, etc.) that simply do not exist in a freestanding WASM environment.
The problem: certain parts of the standard library (including std.debug, the default panic handler, and anything that formats error messages) pull in std.Io.Threaded transitively. If your WASM code reaches any of those code paths — even accidentally — the compiler will fail with:
lib/std/Io/Threaded.zig:2064:45: error: struct 'posix.system...' has no member named 'getrandom'
The fix
Add this declaration at the top level of your src/main.zig (or a dedicated src/wasm_options.zig file imported first):
// src/main.zig
// REQUIRED for wasm32-freestanding in Zig 0.16.0.
// This replaces the default std.Io.Threaded debug implementation with
// std.Io.failing, which compiles for all targets (including freestanding)
// and simply returns an error if any I/O operation is attempted at runtime.
pub const std_options_debug_io: std.Io = std.Io.failing;
const std = @import("std");
export fn add(a: i32, b: i32) i32 {
return a + b;
}
Making it target-conditional
If your Zig codebase also compiles for native targets where you do want real I/O, make the override conditional:
const builtin = @import("builtin");
// Only override the debug Io implementation for freestanding WASM.
// On native platforms, the default std.Io.Threaded provides real I/O.
pub const std_options_debug_io: std.Io = if (builtin.target.isWasm() and
builtin.target.os.tag == .freestanding)
std.Io.failing
else
std.Options.debug_threaded_io.?.io();
What you CAN use without triggering the issue
After applying the fix, these standard library features work correctly in freestanding WASM:
std.math.*— all math functionsstd.mem.*— all memory utilitiesstd.fmt.bufPrint— format to a fixed-size buffer (does not usestd.Io)std.sort.*— sorting algorithmsstd.unicode.*— UTF-8 utilitiesstd.Io.Writer.fixed(&buf)— fixed-buffer writer (does not useThreaded)std.crypto.*— cryptographic primitives@import("builtin")— target information
What to avoid:
std.debug.print— usesstd.Io.Threadedstd.debug.assertwith failure messages — can trigger the debug I/O pathstd.io.*— the entire old namespace is gone; usestd.Io.*
10. The Linear Memory Model
Linear memory is the shared address space between your Zig WASM module and JavaScript. Understanding its layout is non-negotiable for writing correct cross-boundary code.
How memory is declared
Zig's WASM linker automatically declares a linear memory in the module. The initial size is determined by how much static data your module contains, rounded up to the nearest page. You can request additional minimum pages in build.zig:
exe.initial_memory = 2 * 65536; // 2 pages = 128 KB minimum
exe.max_memory = 256 * 65536; // 256 pages = 16 MB maximum (optional cap)
If you omit max_memory, WASM allows the memory to grow without bound (up to the host's limit).
Typical layout of a compiled Zig WASM module
Address 0x00000 [ Shadow stack pointer (4 bytes) ]
Address 0x00004 [ Stack (grows down toward 0) ]
...
Address 0x0xxxx [ Bottom of stack ]
Address 0x0yyyy [ Static data: global variables, string literals ]
...
Address 0x10000 [ Heap starts here (if using page_allocator) ]
...
[ WASM pages grow upward as needed ]
The exact layout depends on your code. Inspect it with:
wasm2wat zig-out/bin/app.wasm | head -30
Look for (memory ...) to see the initial page count, and (data ...) entries to see where static data lands.
JavaScript views into linear memory
From JavaScript, you access linear memory as a typed array view on instance.exports.memory.buffer:
const memory = instance.exports.memory;
// Byte-level view (most universal)
const u8 = new Uint8Array(memory.buffer);
const i32 = new Int32Array(memory.buffer);
const f32 = new Float32Array(memory.buffer);
const f64 = new Float64Array(memory.buffer);
// Reading a u32 at WASM address 1024:
const value = new Uint32Array(memory.buffer)[1024 / 4];
// Reading a null-terminated string starting at WASM address 2048:
function readCStr(ptr) {
const bytes = new Uint8Array(memory.buffer);
let end = ptr;
while (bytes[end] !== 0) end++;
return new TextDecoder().decode(bytes.subarray(ptr, end));
}
Memory growth
WASM memory can grow at runtime using the memory.grow instruction. Zig's std.heap.wasm_allocator (the correct name in 0.16.0 for the WASM page allocator) uses this under the hood. From JavaScript, you can also grow memory:
memory.grow(4); // Grow by 4 more pages (256 KB)
// After growing, always re-read memory.buffer — the old reference is invalid:
const newView = new Uint8Array(memory.buffer);
Critical: After any memory growth (whether from Zig code or JS), all existing JavaScript typed array references to memory.buffer become detached. You must re-read memory.buffer from the memory object.
Static allocation pattern
For simple cases where you need a fixed-size buffer accessible from both Zig and JavaScript:
// A 64 KB scratch buffer at a known location.
// `align(4096)` ensures page alignment, making it easy to find
// the buffer from JavaScript by calling alloc_scratch_ptr().
var scratch: [65536]u8 align(4096) = undefined;
export fn scratch_ptr() [*]u8 { return &scratch; }
export fn scratch_len() usize { return scratch.len; }
JavaScript:
const ptr = instance.exports.scratch_ptr();
const len = instance.exports.scratch_len();
const view = new Uint8Array(instance.exports.memory.buffer, ptr, len);
11. Memory Allocators in Freestanding WASM
Zig's allocator interface (std.mem.Allocator) works in freestanding WASM with some caveats.
std.heap.wasm_allocator
This is the correct general-purpose allocator for wasm32-freestanding in 0.16.0. It acquires whole WASM memory pages via the memory.grow instruction and implements a simple free list. It is the equivalent of malloc/free in the WASM context.
// src/main.zig
pub const std_options_debug_io: std.Io = std.Io.failing;
const std = @import("std");
var gpa = std.heap.wasm_allocator;
export fn allocate_buffer(n: usize) [*]u8 {
const slice = gpa.alloc(u8, n) catch return @ptrFromInt(0);
return slice.ptr;
}
export fn free_buffer(ptr: [*]u8, n: usize) void {
gpa.free(ptr[0..n]);
}
std.heap.FixedBufferAllocator
For bounded allocations with a known upper bound, a FixedBufferAllocator over a static array is the most predictable option and produces zero WASM memory growth:
var heap_buf: [1024 * 1024]u8 = undefined; // 1 MB static arena
var heap_fba = std.heap.FixedBufferAllocator.init(&heap_buf);
const allocator = heap_fba.allocator();
This is the right choice for temporary per-request allocations in a stateless exported function.
std.heap.ArenaAllocator
ArenaAllocator wraps another allocator and allows you to free all allocations at once by calling arena.deinit(). In 0.16.0, std.heap.ArenaAllocator is thread-safe and lock-free (a new improvement). For WASM it's particularly useful in combination with wasm_allocator:
var arena = std.heap.ArenaAllocator.init(std.heap.wasm_allocator);
defer arena.deinit();
const allocator = arena.allocator();
// All allocations through `allocator` are freed in one shot by arena.deinit().
12. Importing JavaScript Functions
To call JavaScript from Zig WASM, declare the function with extern. In WASM, all extern functions are imports from the import table, and the import module defaults to "env". Use the explicit extern "env" syntax for clarity.
Basic import declaration
// All functions declared with extern "env" become entries in the WASM
// (import "env" "function_name" ...) section.
extern "env" fn js_log(ptr: [*]const u8, len: usize) void;
extern "env" fn js_now() f64; // performance.now()
extern "env" fn js_random() f64; // Math.random()
extern "env" fn js_abort(msg_ptr: [*]const u8, msg_len: usize) noreturn;
JavaScript instantiation:
const imports = {
env: {
js_log(ptr, len) {
const bytes = new Uint8Array(instance.exports.memory.buffer, ptr, len);
console.log(new TextDecoder().decode(bytes));
},
js_now() {
return performance.now();
},
js_random() {
return Math.random();
},
js_abort(ptr, len) {
const bytes = new Uint8Array(instance.exports.memory.buffer, ptr, len);
throw new Error(new TextDecoder().decode(bytes));
},
},
};
const { instance } = await WebAssembly.instantiateStreaming(fetch("app.wasm"), imports);
Using imports in exported functions
extern "env" fn js_log(ptr: [*]const u8, len: usize) void;
fn log(comptime msg: []const u8) void {
js_log(msg.ptr, msg.len);
}
export fn greet() void {
log("Hello from Zig 0.16.0!");
}
Custom panic handler using JS
In freestanding WASM you can redirect Zig panics to JavaScript:
extern "env" fn js_panic(ptr: [*]const u8, len: usize) noreturn;
// Override Zig's default panic handler.
pub fn panic(msg: []const u8, _: ?*std.builtin.StackTrace, _: ?usize) noreturn {
js_panic(msg.ptr, msg.len);
}
JavaScript:
js_panic(ptr, len) {
const msg = new TextDecoder().decode(
new Uint8Array(instance.exports.memory.buffer, ptr, len)
);
throw new Error(`Zig panic: ${msg}`);
},
13. Passing Complex Data Across the Boundary
Every real-world WASM module needs to pass more than integers. Here is the complete set of patterns.
Pattern 1: Caller (JS) writes, Zig reads
JavaScript encodes a string into WASM memory, then calls an exported function with the pointer and length:
// Zig: takes a slice of bytes by pointer + length
export fn process_name(ptr: [*]const u8, len: usize) u32 {
const name: []const u8 = ptr[0..len];
// Count capital letters as an example.
var count: u32 = 0;
for (name) |byte| {
if (byte >= 'A' and byte <= 'Z') count += 1;
}
return count;
}
// JavaScript: write string into WASM memory at a known offset
const SCRATCH = 65536; // Pick an offset past the stack. Export it from Zig for safety.
function callWithString(fn_, str) {
const encoded = new TextEncoder().encode(str);
const view = new Uint8Array(instance.exports.memory.buffer, SCRATCH, encoded.length);
view.set(encoded);
return fn_(SCRATCH, encoded.length);
}
const capitals = callWithString(instance.exports.process_name, "Hello World");
console.log(capitals); // 2
Pattern 2: Zig writes, Caller (JS) reads
Zig writes a result into a static buffer and returns a pointer:
// A static result buffer. The size must be enough for the largest possible output.
var result_buf: [512]u8 = undefined;
var result_len: usize = 0;
export fn get_result_ptr() [*]const u8 { return &result_buf; }
export fn get_result_len() usize { return result_len; }
export fn to_hex(value: u32) void {
const s = std.fmt.bufPrint(&result_buf, "{x}", .{value}) catch unreachable;
result_len = s.len;
}
instance.exports.to_hex(255);
const ptr = instance.exports.get_result_ptr();
const len = instance.exports.get_result_len();
const str = new TextDecoder().decode(
new Uint8Array(instance.exports.memory.buffer, ptr, len)
);
console.log(str); // "ff"
Pattern 3: Returning a struct via pointer
// The struct must have a known, stable memory layout.
const Stats = extern struct {
min: f64,
max: f64,
mean: f64,
count: u32,
_pad: u32 = 0, // explicit padding to align f64 boundary
};
var stats_result: Stats = undefined;
export fn compute_stats(ptr: [*]const f64, len: usize) [*]const Stats {
var min = ptr[0];
var max = ptr[0];
var sum: f64 = 0;
for (ptr[0..len]) |v| {
if (v < min) min = v;
if (v > max) max = v;
sum += v;
}
stats_result = .{
.min = min,
.max = max,
.mean = sum / @as(f64, @floatFromInt(len)),
.count = @intCast(len),
};
return &stats_result;
}
// Write 5 doubles, call compute_stats, read the Stats struct fields.
const DATA_OFFSET = 4096;
const f64view = new Float64Array(instance.exports.memory.buffer, DATA_OFFSET, 5);
f64view.set([3.0, 1.0, 4.0, 1.0, 5.0]);
const statsPtr = instance.exports.compute_stats(DATA_OFFSET, 5);
// Stats layout: f64 min (8), f64 max (8), f64 mean (8), u32 count (4), u32 pad (4) = 32 bytes
const statsView = new DataView(instance.exports.memory.buffer, statsPtr, 32);
console.log("min:", statsView.getFloat64(0, true)); // 1.0
console.log("max:", statsView.getFloat64(8, true)); // 5.0
console.log("mean:", statsView.getFloat64(16, true)); // 2.8
console.log("count:", statsView.getUint32(24, true)); // 5
The true argument to getFloat64/getUint32 specifies little-endian byte order, which is what WASM uses.
14. Formatting Strings in WASM Memory
std.fmt.bufPrint is the safe, allocation-free way to format values into a Zig byte slice in freestanding WASM. Unlike std.Io.Writer, it does not pull in std.Io.Threaded.
var fmt_buf: [256]u8 = undefined;
var fmt_len: usize = 0;
export fn fmt_ptr() [*]const u8 { return &fmt_buf; }
export fn fmt_len_val() usize { return fmt_len; }
export fn format_float(value: f64, decimal_places: u8) void {
const s = std.fmt.bufPrint(&fmt_buf, "{d:.{d}}", .{ value, decimal_places })
catch |_| blk: {
// On overflow (shouldn't happen with 256 bytes), truncate.
fmt_len = 0;
break :blk fmt_buf[0..0];
};
fmt_len = s.len;
}
export fn format_int(value: i64, base: u8) void {
const s = switch (base) {
10 => std.fmt.bufPrint(&fmt_buf, "{d}", .{value}),
16 => std.fmt.bufPrint(&fmt_buf, "{x}", .{value}),
2 => std.fmt.bufPrint(&fmt_buf, "{b}", .{value}),
else => std.fmt.bufPrint(&fmt_buf, "{d}", .{value}),
} catch unreachable;
fmt_len = s.len;
}
If you do need a std.Io.Writer for more complex formatting (multiple writes, streaming output), use std.Io.Writer.fixed:
const Writer = std.Io.Writer;
export fn format_multiple() void {
var writer = Writer.fixed(&fmt_buf);
writer.print("x={d}, y={d}, z={d}", .{ 1, 2, 3 }) catch {};
fmt_len = writer.end;
}
Note: writer.end tracks the number of bytes written to the fixed buffer in 0.16.0's Writer.fixed implementation.
Project 1: Arithmetic & Bit Operations Library
A complete, production-quality numeric utility module. All functions are pure (no side effects outside their return value), all types are explicit, and the JS integration pattern is shown in full.
src/main.zig
pub const std_options_debug_io: std.Io = std.Io.failing;
const std = @import("std");
// ─── Integer Arithmetic ──────────────────────────────────────────────────────
export fn add_i32(a: i32, b: i32) i32 { return a +% b; } // wrapping add
export fn sub_i32(a: i32, b: i32) i32 { return a -% b; }
export fn mul_i32(a: i32, b: i32) i32 { return a *% b; }
/// Signed division. Returns 0 on division-by-zero (signal: check separately).
export fn div_i32(a: i32, b: i32) i32 {
if (b == 0) return 0;
return @divTrunc(a, b);
}
export fn mod_i32(a: i32, b: i32) i32 {
if (b == 0) return 0;
return @rem(a, b);
}
export fn pow_i64(base: i64, exp: u32) i64 {
var result: i64 = 1;
var b: i64 = base;
var e: u32 = exp;
while (e > 0) : (e >>= 1) {
if (e & 1 == 1) result *%= b;
b *%= b;
}
return result;
}
export fn gcd_u32(a: u32, b: u32) u32 {
var x: u32 = a;
var y: u32 = b;
while (y != 0) {
const t = y;
y = x % y;
x = t;
}
return x;
}
export fn lcm_u32(a: u32, b: u32) u32 {
if (a == 0 or b == 0) return 0;
return a / gcd_u32(a, b) *% b;
}
// ─── Float Arithmetic ────────────────────────────────────────────────────────
export fn add_f64(a: f64, b: f64) f64 { return a + b; }
export fn sub_f64(a: f64, b: f64) f64 { return a - b; }
export fn mul_f64(a: f64, b: f64) f64 { return a * b; }
export fn div_f64(a: f64, b: f64) f64 {
// IEEE 754: dividing by zero produces +Inf or -Inf, not a trap.
// We return NaN as an explicit error sentinel.
if (b == 0.0) return std.math.nan(f64);
return a / b;
}
export fn sqrt_f64(x: f64) f64 {
if (x < 0.0) return std.math.nan(f64);
return @sqrt(x);
}
export fn pow_f64(base: f64, exp: f64) f64 {
return std.math.pow(f64, base, exp);
}
export fn log_f64(x: f64) f64 {
if (x <= 0.0) return std.math.nan(f64);
return @log(x);
}
export fn log2_f64(x: f64) f64 {
if (x <= 0.0) return std.math.nan(f64);
return @log2(x);
}
export fn log10_f64(x: f64) f64 {
if (x <= 0.0) return std.math.nan(f64);
return @log10(x);
}
export fn sin_f64(x: f64) f64 { return @sin(x); }
export fn cos_f64(x: f64) f64 { return @cos(x); }
export fn tan_f64(x: f64) f64 { return @tan(x); }
export fn clamp_f64(val: f64, lo: f64, hi: f64) f64 {
return @min(@max(val, lo), hi);
}
export fn lerp_f64(a: f64, b: f64, t: f64) f64 {
return a + (b - a) * t;
}
// ─── Bit Operations ──────────────────────────────────────────────────────────
/// Count set bits (popcount) in a u32.
export fn popcnt_u32(x: u32) u32 { return @popCount(x); }
/// Count leading zeros in a u32. Returns 32 if x == 0.
export fn clz_u32(x: u32) u32 { return @clz(x); }
/// Count trailing zeros in a u32. Returns 32 if x == 0.
export fn ctz_u32(x: u32) u32 { return @ctz(x); }
/// Rotate left by n bits.
export fn rotl_u32(x: u32, n: u5) u32 { return std.math.rotl(u32, x, n); }
/// Rotate right by n bits.
export fn rotr_u32(x: u32, n: u5) u32 { return std.math.rotr(u32, x, n); }
/// Reverse the bit order of x.
export fn bitrev_u32(x: u32) u32 { return @bitReverse(x); }
/// Byte-swap a u32 (convert endianness).
export fn bswap_u32(x: u32) u32 { return @byteSwap(x); }
// ─── Utility ─────────────────────────────────────────────────────────────────
export fn abs_i32(x: i32) i32 { return if (x < 0) -%x else x; }
export fn min_i32(a: i32, b: i32) i32 { return if (a < b) a else b; }
export fn max_i32(a: i32, b: i32) i32 { return if (a > b) a else b; }
export fn factorial_u64(n: u32) u64 {
if (n > 20) return 0; // 21! overflows u64
var result: u64 = 1;
var i: u32 = 2;
while (i <= n) : (i += 1) result *%= i;
return result;
}
/// n-th Fibonacci number. Returns 0 for n > 93 (overflows u64).
export fn fibonacci_u64(n: u32) u64 {
if (n > 93) return 0;
if (n <= 1) return @as(u64, n);
var a: u64 = 0;
var b: u64 = 1;
var i: u32 = 2;
while (i <= n) : (i += 1) {
const c = a +% b;
a = b;
b = c;
}
return b;
}
www/math.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Zig Math WASM</title></head>
<body>
<pre id="out"></pre>
<script type="module">
const { instance } = await WebAssembly.instantiateStreaming(
fetch("app.wasm"), { env: {} }
);
const m = instance.exports;
const out = document.getElementById("out");
const log = (...args) => { out.textContent += args.join(" ") + "\n"; };
log("add_i32(7, -3) =", m.add_i32(7, -3)); // 4
log("gcd_u32(48, 18) =", m.gcd_u32(48, 18)); // 6
log("lcm_u32(4, 6) =", m.lcm_u32(4, 6)); // 12
log("pow_i64(2, 10) =", m.pow_i64(2n, 10)); // 1024
log("factorial_u64(10) =", m.factorial_u64(10)); // 3628800
log("fibonacci_u64(10) =", m.fibonacci_u64(10)); // 55
log("sqrt_f64(2) =", m.sqrt_f64(2)); // 1.4142...
log("popcnt_u32(0b1011) =", m.popcnt_u32(0b1011)); // 3
log("clz_u32(1 << 30) =", m.clz_u32(1 << 30)); // 1
log("bswap_u32(0x12345678)=", m.bswap_u32(0x12345678).toString(16)); // 78563412
log("div_f64(1, 0) =", m.div_f64(1, 0)); // NaN
</script>
</body>
</html>
Project 2: String Operations Module
The challenge: Zig strings are []const u8 slices. WASM can only pass integers. This project builds a complete string utility module with the pointer+length protocol, showing how to handle encoding and the full read/write cycle.
src/main.zig
pub const std_options_debug_io: std.Io = std.Io.failing;
const std = @import("std");
// Shared output buffer for functions that return strings.
var out_buf: [4096]u8 = undefined;
var out_len: usize = 0;
export fn out_ptr() [*]const u8 { return &out_buf; }
export fn out_len_val() usize { return out_len; }
// ─── Queries (read-only) ──────────────────────────────────────────────────────
/// Byte length of a UTF-8 string. For ASCII this equals character count.
export fn byte_len(ptr: [*]const u8, len: usize) usize {
return len;
}
/// Count UTF-8 codepoints (not bytes). Handles multi-byte sequences correctly.
export fn codepoint_count(ptr: [*]const u8, len: usize) usize {
const s = ptr[0..len];
var count: usize = 0;
var i: usize = 0;
while (i < s.len) {
const byte = s[i];
// Determine byte width of current codepoint.
const width: usize = if (byte < 0x80) 1
else if (byte < 0xE0) 2
else if (byte < 0xF0) 3
else 4;
i += width;
count += 1;
}
return count;
}
/// Count occurrences of `needle` (a single byte) in `haystack`.
export fn count_byte(hay_ptr: [*]const u8, hay_len: usize, needle: u8) usize {
const s = hay_ptr[0..hay_len];
var count: usize = 0;
for (s) |byte| {
if (byte == needle) count += 1;
}
return count;
}
/// 1 if haystack starts with prefix, 0 otherwise.
export fn starts_with(
hay_ptr: [*]const u8, hay_len: usize,
pre_ptr: [*]const u8, pre_len: usize,
) u32 {
const hay = hay_ptr[0..hay_len];
const pre = pre_ptr[0..pre_len];
return if (std.mem.startsWith(u8, hay, pre)) 1 else 0;
}
/// 1 if haystack ends with suffix, 0 otherwise.
export fn ends_with(
hay_ptr: [*]const u8, hay_len: usize,
suf_ptr: [*]const u8, suf_len: usize,
) u32 {
const hay = hay_ptr[0..hay_len];
const suf = suf_ptr[0..suf_len];
return if (std.mem.endsWith(u8, hay, suf)) 1 else 0;
}
/// Return the byte offset of the first occurrence of `needle` byte in `haystack`.
/// Returns usize_max (0xFFFFFFFF on wasm32) if not found.
export fn find_byte(hay_ptr: [*]const u8, hay_len: usize, needle: u8) usize {
const s = hay_ptr[0..hay_len];
for (s, 0..) |byte, i| {
if (byte == needle) return i;
}
return std.math.maxInt(usize);
}
// ─── Transformations (write to out_buf) ──────────────────────────────────────
/// Convert ASCII string to uppercase in-place in out_buf.
export fn to_upper(ptr: [*]const u8, len: usize) void {
const s = ptr[0..len];
const n = @min(len, out_buf.len);
for (s[0..n], 0..) |byte, i| {
out_buf[i] = if (byte >= 'a' and byte <= 'z') byte - 32 else byte;
}
out_len = n;
}
/// Convert ASCII string to lowercase in-place in out_buf.
export fn to_lower(ptr: [*]const u8, len: usize) void {
const s = ptr[0..len];
const n = @min(len, out_buf.len);
for (s[0..n], 0..) |byte, i| {
out_buf[i] = if (byte >= 'A' and byte <= 'Z') byte + 32 else byte;
}
out_len = n;
}
/// Reverse the bytes of a string into out_buf.
export fn reverse(ptr: [*]const u8, len: usize) void {
const s = ptr[0..len];
const n = @min(len, out_buf.len);
for (0..n) |i| {
out_buf[i] = s[n - 1 - i];
}
out_len = n;
}
/// Trim leading and trailing ASCII whitespace (space, tab, CR, LF).
export fn trim(ptr: [*]const u8, len: usize) void {
const s = ptr[0..len];
const ws = " \t\r\n";
const trimmed = std.mem.trim(u8, s, ws);
const n = @min(trimmed.len, out_buf.len);
@memcpy(out_buf[0..n], trimmed[0..n]);
out_len = n;
}
/// Repeat `s` exactly `n` times, written to out_buf.
/// Truncates silently if the result exceeds 4096 bytes.
export fn repeat(ptr: [*]const u8, len: usize, n: u32) void {
out_len = 0;
var written: usize = 0;
var i: u32 = 0;
while (i < n and written + len <= out_buf.len) : (i += 1) {
@memcpy(out_buf[written..][0..len], ptr[0..len]);
written += len;
}
out_len = written;
}
www/strings.js
const { instance } = await WebAssembly.instantiateStreaming(
fetch("app.wasm"), { env: {} }
);
const exports = instance.exports;
const mem = () => instance.exports.memory.buffer;
// Helper: write a JS string into WASM memory at a given offset.
// Returns { ptr, len } for the encoded bytes.
function encode(str, offset = 65536) {
const bytes = new TextEncoder().encode(str);
new Uint8Array(mem(), offset, bytes.length).set(bytes);
return { ptr: offset, len: bytes.length };
}
// Helper: read a string from WASM's out_buf.
function readOut() {
const ptr = exports.out_ptr();
const len = exports.out_len_val();
const bytes = new Uint8Array(mem(), ptr, len);
return new TextDecoder().decode(bytes);
}
// --- Usage ---
const { ptr, len } = encode("Hello, 🌍 World!");
console.log("byte_len: ", exports.byte_len(ptr, len)); // 20 (UTF-8 bytes)
console.log("codepoint_count:", exports.codepoint_count(ptr, len)); // 16 (chars)
console.log("count_byte '!': ", exports.count_byte(ptr, len, 33)); // 1
exports.to_upper(ptr, len);
console.log("to_upper: ", readOut()); // "HELLO, 🌍 WORLD!" (non-ASCII unchanged)
exports.reverse(ptr, len);
console.log("reverse: ", readOut());
const { ptr: p2, len: l2 } = encode(" trim me ", 65600);
exports.trim(p2, l2);
console.log("trim: ", `"${readOut()}"`); // "trim me"
const { ptr: p3, len: l3 } = encode("ab", 65700);
exports.repeat(p3, l3, 4);
console.log("repeat x4: ", readOut()); // "abababab"
Project 3: Sorting and Statistics Engine
A generic sort and descriptive statistics module. This demonstrates WASM operating on large arrays entirely in linear memory, with JavaScript only supplying the pointer and reading the results.
src/main.zig
pub const std_options_debug_io: std.Io = std.Io.failing;
const std = @import("std");
// ─── Sorting ─────────────────────────────────────────────────────────────────
/// Sort an array of f64 values in-place (ascending). Uses std.sort.pdq.
export fn sort_f64(ptr: [*]f64, len: usize) void {
const slice = ptr[0..len];
std.sort.pdq(f64, slice, {}, std.sort.asc(f64));
}
/// Sort an array of i32 values in-place (ascending).
export fn sort_i32(ptr: [*]i32, len: usize) void {
const slice = ptr[0..len];
std.sort.pdq(i32, slice, {}, std.sort.asc(i32));
}
/// Sort an array of u32 values in-place (ascending).
export fn sort_u32(ptr: [*]u32, len: usize) void {
const slice = ptr[0..len];
std.sort.pdq(u32, slice, {}, std.sort.asc(u32));
}
/// Binary search a sorted f64 slice. Returns index or usize_max if absent.
export fn bsearch_f64(ptr: [*]const f64, len: usize, target: f64) usize {
const slice = ptr[0..len];
return std.sort.binarySearch(f64, target, slice, {}, std.sort.asc(f64)) orelse
std.math.maxInt(usize);
}
// ─── Statistics ───────────────────────────────────────────────────────────────
export fn sum_f64(ptr: [*]const f64, len: usize) f64 {
var total: f64 = 0;
for (ptr[0..len]) |v| total += v;
return total;
}
export fn min_f64(ptr: [*]const f64, len: usize) f64 {
if (len == 0) return std.math.nan(f64);
var m = ptr[0];
for (ptr[1..len]) |v| if (v < m) { m = v; };
return m;
}
export fn max_f64(ptr: [*]const f64, len: usize) f64 {
if (len == 0) return std.math.nan(f64);
var m = ptr[0];
for (ptr[1..len]) |v| if (v > m) { m = v; };
return m;
}
export fn mean_f64(ptr: [*]const f64, len: usize) f64 {
if (len == 0) return std.math.nan(f64);
return sum_f64(ptr, len) / @as(f64, @floatFromInt(len));
}
/// Variance (population). Requires the array to be non-empty.
export fn variance_f64(ptr: [*]const f64, len: usize) f64 {
if (len == 0) return std.math.nan(f64);
const m = mean_f64(ptr, len);
var sq: f64 = 0;
for (ptr[0..len]) |v| {
const d = v - m;
sq += d * d;
}
return sq / @as(f64, @floatFromInt(len));
}
export fn stddev_f64(ptr: [*]const f64, len: usize) f64 {
return @sqrt(variance_f64(ptr, len));
}
/// Median of an array. MODIFIES the array in-place to sort it first.
/// Sort before calling if you need to preserve order.
export fn median_f64(ptr: [*]f64, len: usize) f64 {
if (len == 0) return std.math.nan(f64);
sort_f64(ptr, len);
const mid = len / 2;
if (len % 2 == 1) {
return ptr[mid];
} else {
return (ptr[mid - 1] + ptr[mid]) / 2.0;
}
}
/// Percentile p (0.0–100.0) of a SORTED array using linear interpolation.
export fn percentile_f64(ptr: [*]const f64, len: usize, p: f64) f64 {
if (len == 0) return std.math.nan(f64);
if (p <= 0.0) return ptr[0];
if (p >= 100.0) return ptr[len - 1];
const rank = (p / 100.0) * @as(f64, @floatFromInt(len - 1));
const lo = @as(usize, @intFromFloat(@floor(rank)));
const hi = @min(lo + 1, len - 1);
const frac = rank - @floor(rank);
return ptr[lo] + frac * (ptr[hi] - ptr[lo]);
}
// ─── Compact return of all stats in one call ──────────────────────────────────
// Returns a pointer to a 6-element f64 array: [min, max, mean, variance, stddev, median]
// NOTE: sort_f64 is called internally, which modifies the input array.
var stats6_buf: [6]f64 = undefined;
export fn full_stats_f64(ptr: [*]f64, len: usize) [*]f64 {
stats6_buf[0] = min_f64(ptr, len);
stats6_buf[1] = max_f64(ptr, len);
stats6_buf[2] = mean_f64(ptr, len);
stats6_buf[3] = variance_f64(ptr, len);
stats6_buf[4] = stddev_f64(ptr, len);
stats6_buf[5] = median_f64(ptr, len); // also sorts ptr
return &stats6_buf;
}
JavaScript usage
const DATA = 4096; // Offset in WASM memory where we'll write our array
const N = 8; // Number of elements
// Write 8 doubles
const view = new Float64Array(instance.exports.memory.buffer, DATA, N);
view.set([7, 2, 4, 1, 9, 3, 5, 6]);
// Sort in-place
instance.exports.sort_f64(DATA, N);
const sorted = [...new Float64Array(instance.exports.memory.buffer, DATA, N)];
console.log("Sorted:", sorted); // [1, 2, 3, 4, 5, 6, 7, 9]
// Binary search for 5
const idx = instance.exports.bsearch_f64(DATA, N, 5);
console.log("Index of 5:", idx); // 4
// Reset and get full stats (sort is applied internally)
new Float64Array(instance.exports.memory.buffer, DATA, N).set([7, 2, 4, 1, 9, 3, 5, 6]);
const statsPtr = instance.exports.full_stats_f64(DATA, N);
const stats = new Float64Array(instance.exports.memory.buffer, statsPtr, 6);
console.log("min:", stats[0]); // 1
console.log("max:", stats[1]); // 9
console.log("mean:", stats[2]); // 4.625
console.log("variance:", stats[3]); // ~7.234
console.log("stddev:", stats[4]); // ~2.690
console.log("median:", stats[5]); // 4.5 ((4+5)/2)
Project 4: Image Pixel Processing Pipeline
Real pixel manipulation. Zig processes the raw RGBA byte array of an HTML Canvas. This project demonstrates:
- Efficient tight loops over large arrays (8 MB for a 1920×1080 image)
- Multiple filter functions operating on the same memory
- The alloc/free pattern for giving JavaScript a stable WASM buffer
src/main.zig
pub const std_options_debug_io: std.Io = std.Io.failing;
const std = @import("std");
// A large static backing store for image pixel data.
// For 4K resolution: 3840 * 2160 * 4 = 33 MB — use wasm_allocator for that.
// For up to 1080p: 1920 * 1080 * 4 = ~8 MB — fits in a static array.
var pixel_buf: [8_294_400]u8 align(8) = undefined; // 8 MB, 8-byte aligned
export fn pixel_buf_ptr() [*]u8 { return &pixel_buf; }
export fn pixel_buf_capacity() usize { return pixel_buf.len; }
// ─── Filter implementations ───────────────────────────────────────────────────
/// Grayscale using BT.601 luminance coefficients (integer approximation).
/// R: 0.299 ≈ 77/256, G: 0.587 ≈ 150/256, B: 0.114 ≈ 29/256.
export fn filter_grayscale(len: usize) void {
var i: usize = 0;
while (i + 3 < len) : (i += 4) {
const r: u32 = pixel_buf[i];
const g: u32 = pixel_buf[i + 1];
const b: u32 = pixel_buf[i + 2];
const luma: u8 = @intCast((r * 77 + g * 150 + b * 29) >> 8);
pixel_buf[i] = luma;
pixel_buf[i + 1] = luma;
pixel_buf[i + 2] = luma;
// Alpha (pixel_buf[i + 3]) is preserved.
}
}
/// Invert all RGB channels.
export fn filter_invert(len: usize) void {
var i: usize = 0;
while (i + 3 < len) : (i += 4) {
pixel_buf[i] = 255 - pixel_buf[i];
pixel_buf[i + 1] = 255 - pixel_buf[i + 1];
pixel_buf[i + 2] = 255 - pixel_buf[i + 2];
}
}
/// Sepia tone using the standard matrix coefficients.
export fn filter_sepia(len: usize) void {
var i: usize = 0;
while (i + 3 < len) : (i += 4) {
const r: u32 = pixel_buf[i];
const g: u32 = pixel_buf[i + 1];
const b: u32 = pixel_buf[i + 2];
// Coefficients scaled by 256 to avoid float arithmetic.
// nr = 0.393R + 0.769G + 0.189B
// ng = 0.349R + 0.686G + 0.168B
// nb = 0.272R + 0.534G + 0.131B
pixel_buf[i] = @intCast(@min(255, (r * 101 + g * 197 + b * 48) >> 8));
pixel_buf[i + 1] = @intCast(@min(255, (r * 89 + g * 176 + b * 43) >> 8));
pixel_buf[i + 2] = @intCast(@min(255, (r * 70 + g * 137 + b * 34) >> 8));
}
}
/// Brightness adjustment. delta in [-255, 255].
export fn filter_brightness(len: usize, delta: i32) void {
var i: usize = 0;
while (i + 3 < len) : (i += 4) {
inline for (0..3) |c| {
const v: i32 = @as(i32, pixel_buf[i + c]) + delta;
pixel_buf[i + c] = @intCast(@min(255, @max(0, v)));
}
}
}
/// Contrast adjustment. factor is a percentage: 100 = no change, 200 = double.
export fn filter_contrast(len: usize, factor: u32) void {
// Formula: out = clamp((v - 128) * factor/100 + 128, 0, 255)
var i: usize = 0;
while (i + 3 < len) : (i += 4) {
inline for (0..3) |c| {
const v: i32 = @as(i32, pixel_buf[i + c]);
const out: i32 = @divTrunc((v - 128) * @as(i32, @intCast(factor)), 100) + 128;
pixel_buf[i + c] = @intCast(@min(255, @max(0, out)));
}
}
}
/// Horizontal flip in-place. Requires width and height.
export fn filter_flip_h(width: u32, height: u32) void {
const stride: usize = @as(usize, width) * 4;
var row: u32 = 0;
while (row < height) : (row += 1) {
const base: usize = @as(usize, row) * stride;
var l: usize = 0;
var r: usize = stride - 4;
while (l < r) {
// Swap two RGBA pixels.
inline for (0..4) |c| {
const tmp = pixel_buf[base + l + c];
pixel_buf[base + l + c] = pixel_buf[base + r + c];
pixel_buf[base + r + c] = tmp;
}
l += 4;
r -= 4;
}
}
}
/// Box blur (3×3 kernel) — simple, readable. Reads from pixel_buf, writes to tmp_buf.
var tmp_buf: [8_294_400]u8 align(8) = undefined;
export fn filter_box_blur(width: u32, height: u32) void {
const w: usize = width;
const h: usize = height;
for (0..h) |y| {
for (0..w) |x| {
var sr: u32 = 0;
var sg: u32 = 0;
var sb: u32 = 0;
var count: u32 = 0;
const y0: usize = if (y > 0) y - 1 else 0;
const y1: usize = if (y + 1 < h) y + 1 else h - 1;
const x0: usize = if (x > 0) x - 1 else 0;
const x1: usize = if (x + 1 < w) x + 1 else w - 1;
var ny = y0;
while (ny <= y1) : (ny += 1) {
var nx = x0;
while (nx <= x1) : (nx += 1) {
const idx = (ny * w + nx) * 4;
sr += pixel_buf[idx];
sg += pixel_buf[idx + 1];
sb += pixel_buf[idx + 2];
count += 1;
}
}
const dst = (y * w + x) * 4;
tmp_buf[dst] = @intCast(sr / count);
tmp_buf[dst + 1] = @intCast(sg / count);
tmp_buf[dst + 2] = @intCast(sb / count);
tmp_buf[dst + 3] = pixel_buf[dst + 3]; // preserve alpha
}
}
@memcpy(pixel_buf[0..(w * h * 4)], tmp_buf[0..(w * h * 4)]);
}
www/filters.js
const { instance } = await WebAssembly.instantiateStreaming(
fetch("app.wasm"), { env: {} }
);
const exports = instance.exports;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// Draw the source image to canvas first.
const img = document.getElementById("source-img");
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const W = canvas.width;
const H = canvas.height;
const byteLen = W * H * 4;
// Get WASM pixel buffer pointer.
const ptr = exports.pixel_buf_ptr();
// Copy canvas pixel data into WASM memory.
const imgData = ctx.getImageData(0, 0, W, H);
new Uint8Array(instance.exports.memory.buffer, ptr, byteLen).set(imgData.data);
// Apply filters.
exports.filter_grayscale(byteLen);
exports.filter_brightness(byteLen, 20); // +20 brightness after grayscale
// Copy result back and render.
new Uint8ClampedArray(instance.exports.memory.buffer, ptr, byteLen)
.copyTo(imgData.data);
ctx.putImageData(imgData, 0, 0);
// Example of chaining: sepia then flip.
function applyEffect(effect) {
// Reset from canvas.
const fresh = ctx.getImageData(0, 0, W, H);
new Uint8Array(instance.exports.memory.buffer, ptr, byteLen).set(fresh.data);
effect();
const result = new ImageData(
new Uint8ClampedArray(instance.exports.memory.buffer, ptr, byteLen),
W, H
);
ctx.putImageData(result, 0, 0);
}
document.getElementById("btn-sepia").onclick = () => applyEffect(() => exports.filter_sepia(byteLen));
document.getElementById("btn-invert").onclick = () => applyEffect(() => exports.filter_invert(byteLen));
document.getElementById("btn-blur").onclick = () => applyEffect(() => exports.filter_box_blur(W, H));
document.getElementById("btn-flip-h").onclick = () => applyEffect(() => exports.filter_flip_h(W, H));
Project 5: Conway's Game of Life
The canonical WASM demonstration. This version uses a bitpacked representation — each cell is one bit in a u64 word, so a 256×256 grid fits in 8 KB instead of 64 KB. The neighbour-count loop operates on 64-bit words and uses a SWAR (SIMD Within A Register) count technique.
Design choices
- Grid dimensions are compile-time constants for zero-overhead indexing.
- Double-buffering: two bitpacked grids,
frontandback. Each tick reads fromfront, writes toback, then swaps. - JavaScript reads from
frontvia a pointer; it never writes to the grid after initialization. export fn tick()returns the population count so JavaScript can display it without scanning the grid itself.
src/main.zig
pub const std_options_debug_io: std.Io = std.Io.failing;
const std = @import("std");
const COLS = 256; // Must be a multiple of 64.
const ROWS = 256;
const WORDS_PER_ROW = COLS / 64; // 4 u64s per row
const TOTAL_WORDS = ROWS * WORDS_PER_ROW;
var grid_a: [TOTAL_WORDS]u64 = [_]u64{0} ** TOTAL_WORDS;
var grid_b: [TOTAL_WORDS]u64 = [_]u64{0} ** TOTAL_WORDS;
var front: *[TOTAL_WORDS]u64 = &grid_a;
var back: *[TOTAL_WORDS]u64 = &grid_b;
export fn grid_cols() usize { return COLS; }
export fn grid_rows() usize { return ROWS; }
/// Returns a pointer to the front grid's word array for JavaScript rendering.
export fn grid_ptr() [*]const u64 { return front; }
// ─── Cell access helpers ──────────────────────────────────────────────────────
fn get_cell(grid: *const [TOTAL_WORDS]u64, x: usize, y: usize) u1 {
const word_idx = y * WORDS_PER_ROW + x / 64;
const bit_idx = @as(u6, @intCast(x % 64));
return @intCast((grid[word_idx] >> bit_idx) & 1);
}
fn set_cell(grid: *[TOTAL_WORDS]u64, x: usize, y: usize, v: u1) void {
const word_idx = y * WORDS_PER_ROW + x / 64;
const bit_idx = @as(u6, @intCast(x % 64));
if (v == 1) {
grid[word_idx] |= @as(u64, 1) << bit_idx;
} else {
grid[word_idx] &= ~(@as(u64, 1) << bit_idx);
}
}
// ─── Initialization ───────────────────────────────────────────────────────────
export fn clear() void {
@memset(front, 0);
@memset(back, 0);
}
/// LCG-based fill: approximately 50% density, seeded by `seed`.
export fn randomise(seed: u32) void {
var s: u64 = seed;
for (front) |*word| {
// Xorshift64 — fast, good enough for Life randomization.
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
word.* = s;
}
@memset(back, 0);
}
/// Place a glider at pixel (col, row) in the front grid.
export fn seed_glider(col: u32, row: u32) void {
const c = @as(usize, col);
const r = @as(usize, row);
// Glider pattern (column-relative offsets, row-relative offsets):
const offsets = [_][2]usize{
.{ 1, 0 }, .{ 2, 1 },
.{ 0, 2 }, .{ 1, 2 }, .{ 2, 2 },
};
for (offsets) |off| {
set_cell(front, (c + off[0]) % COLS, (r + off[1]) % ROWS, 1);
}
}
// ─── Simulation tick ──────────────────────────────────────────────────────────
/// Compute one generation. Returns total live cell count.
export fn tick() u32 {
var population: u32 = 0;
for (0..ROWS) |y| {
const yn = if (y == 0) ROWS - 1 else y - 1;
const yp = if (y == ROWS - 1) 0 else y + 1;
for (0..COLS) |x| {
const xn = if (x == 0) COLS - 1 else x - 1;
const xp = if (x == COLS - 1) 0 else x + 1;
const n =
@as(u8, get_cell(front, xn, yn)) +
@as(u8, get_cell(front, x, yn)) +
@as(u8, get_cell(front, xp, yn)) +
@as(u8, get_cell(front, xn, y )) +
@as(u8, get_cell(front, xp, y )) +
@as(u8, get_cell(front, xn, yp)) +
@as(u8, get_cell(front, x, yp)) +
@as(u8, get_cell(front, xp, yp));
const alive = get_cell(front, x, y);
const next: u1 = if (alive == 1)
if (n == 2 or n == 3) 1 else 0
else
if (n == 3) 1 else 0;
set_cell(back, x, y, next);
population += next;
}
}
// Swap buffers.
const tmp = front;
front = back;
back = tmp;
return population;
}
www/life.js
const CELL_PX = 4; // Pixels per cell on canvas
const { instance } = await WebAssembly.instantiateStreaming(
fetch("app.wasm"), { env: {} }
);
const wasm = instance.exports;
const COLS = wasm.grid_cols();
const ROWS = wasm.grid_rows();
const canvas = document.getElementById("canvas");
canvas.width = COLS * CELL_PX;
canvas.height = ROWS * CELL_PX;
const ctx = canvas.getContext("2d");
wasm.randomise(Date.now() | 0);
const popEl = document.getElementById("population");
function render() {
// Read bitpacked grid from WASM memory (no copy needed — typed array view).
const ptr = wasm.grid_ptr();
const words = new BigUint64Array(instance.exports.memory.buffer, ptr, COLS / 64 * ROWS);
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#4ade80"; // Tailwind green-400
const wordsPerRow = COLS / 64;
for (let row = 0; row < ROWS; row++) {
for (let wIdx = 0; wIdx < wordsPerRow; wIdx++) {
let word = words[row * wordsPerRow + wIdx];
let bit = 0;
while (word !== 0n) {
// Find lowest set bit.
const tz = Number(BigInt.asUintN(6, word & -word));
// Skip ahead instead of checking each bit individually.
const col = wIdx * 64 + tz;
ctx.fillRect(col * CELL_PX, row * CELL_PX, CELL_PX - 1, CELL_PX - 1);
word &= word - 1n; // Clear lowest set bit.
bit++;
}
}
}
}
let running = true;
let frameCount = 0;
function loop() {
const pop = wasm.tick();
render();
if (frameCount++ % 30 === 0 && popEl) popEl.textContent = `Population: ${pop}`;
if (running) requestAnimationFrame(loop);
}
document.getElementById("btn-pause").onclick = () => { running = !running; if (running) loop(); };
document.getElementById("btn-reset").onclick = () => { wasm.randomise(Date.now() | 0); };
document.getElementById("btn-glider").onclick = () => { wasm.clear(); wasm.seed_glider(10, 10); };
requestAnimationFrame(loop);
The BigUint64Array view and the word & -word trick for finding and clearing the lowest set bit makes the renderer only draw actually-live cells instead of scanning every pixel.
20. Build Optimization
Optimize modes compared
| Flag | Binary size (Life project) | Notes |
|---|---|---|
Debug (default) |
~85 KB | Stack traces, safety checks, assertions enabled |
ReleaseSafe |
~12 KB | Optimised; safety checks remain (bounds, overflow) |
ReleaseFast |
~10 KB | Max speed; safety checks removed |
ReleaseSmall |
~6 KB | Smallest code; LLVM size-optimises; some speed lost |
For browser WASM, the download size matters more than raw CPU speed in most use cases. Start with ReleaseSmall. Switch to ReleaseFast only when profiling shows a bottleneck.
zig build -Doptimize=ReleaseSmall
wasm-opt passes
wasm-opt (part of the Binaryen toolkit) applies additional transformations after Zig's LLVM backend finishes:
# Install once
npm install -g binaryen
# Size-optimise (roughly 10-15% additional reduction after ReleaseSmall)
wasm-opt -Oz zig-out/bin/app.wasm -o www/app.wasm
# Speed-optimise
wasm-opt -O3 zig-out/bin/app.wasm -o www/app.wasm
# Both size and speed (useful for medium-complexity modules)
wasm-opt -O2 --precompute-propagate zig-out/bin/app.wasm -o www/app.wasm
Integrate into build.zig:
const wasm_opt = b.addSystemCommand(&.{
"wasm-opt", "-Oz",
b.fmt("{s}/bin/app.wasm", .{b.install_path}),
"-o",
b.fmt("{s}/bin/app.wasm", .{b.install_path}),
});
wasm_opt.step.dependOn(b.getInstallStep());
b.step("opt", "Build then run wasm-opt").dependOn(&wasm_opt.step);
Stripping debug sections
In Debug and ReleaseSafe builds, Zig embeds DWARF debug information in the custom sections of the .wasm file. In production, strip it:
wasm-strip zig-out/bin/app.wasm # from the wabt package
Or use wasm-opt --strip-debug as part of the -Oz pass (it already does this).
Import memory from JavaScript
For very tight size budgets, exporting the memory declaration from Zig and importing it from JavaScript saves about 8 bytes in the module header but — more usefully — gives JavaScript full control over initial and maximum memory:
// build.zig
exe.import_memory = true;
exe.initial_memory = 2 * 65536; // minimum 2 pages
const memory = new WebAssembly.Memory({ initial: 2, maximum: 256 });
const { instance } = await WebAssembly.instantiateStreaming(
fetch("app.wasm"),
{ env: { memory } }
);
// Use `memory` for typed array views rather than instance.exports.memory.
21. Debugging WASM Modules
Chrome DevTools
Chrome (and Chromium-based browsers) supports WASM debugging natively when the module contains DWARF debug sections. In Debug or ReleaseSafe builds, Chrome will:
- Show Zig source files in the Sources panel (requires DWARF extension).
- Allow breakpoints inside Zig functions.
- Display local variable values in the Scope panel.
Enable: open DevTools → Settings → Experiments → WebAssembly Debugging: Enable DWARF support.
Reading WAT
The most universally reliable debugging tool is reading the text format. After adding a breakpoint or unexpected behavior, generate the WAT:
wasm2wat zig-out/bin/app.wasm -o debug.wat
# Then search for your function name:
grep -n '"add"' debug.wat
Inspect the function body for correct stack machine behavior — local.get 0, local.get 1, i32.add should match your Zig source.
Validating the module
wasm-validate zig-out/bin/app.wasm
# Prints nothing on success; errors on malformed bytecode.
Run this after every build if you're doing low-level work like manipulating memory layout.
JavaScript runtime error messages
WASM traps (integer divide by zero, out-of-bounds memory access, unreachable) surface as RuntimeError: WebAssembly.Instance() in the browser console. The message includes the function index and bytecode offset. Use wasm2wat to find which function index corresponds to which Zig function:
wasm2wat zig-out/bin/app.wasm | grep '(func' | head -20
# (func $zigFunctionName ...) -- Zig preserves function names in Debug builds
22. WASI: Running Zig WASM Outside the Browser
WASI gives WASM access to a well-defined, OS-like interface without requiring an actual OS. It's designed for server-side and tool use cases. In Zig 0.16.0, wasm32-wasi is a Tier-2 target with full standard library support.
build.zig for WASI
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .wasi,
});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "cli_tool",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
// For WASI: do NOT set exe.entry = .disabled.
// The _start function generated from pub fn main() IS the entry point.
// Do NOT set exe.rdynamic = true unless you also have exported functions.
b.installArtifact(exe);
}
src/main.zig for WASI
WASI's major advantage: std.Io and std.fs work correctly because Wasmtime (or any WASI host) provides the necessary POSIX-like syscalls. No std_options_debug_io override is needed.
const std = @import("std");
// Zig 0.16.0 "juicy main" — pub fn main() can receive io and args directly.
pub fn main() !void {
// Acquire the Io instance provided by the WASI host via juicy main.
// In Zig 0.16.0, main receives std.Io as a parameter when using juicy main.
// For simplicity, use the global single-threaded instance.
const io = std.Io.Threaded.global_single_threaded.io();
const stdout = io.stdout();
// Read environment variable
const home = std.process.getEnvVarOwned(
std.heap.wasm_allocator, "HOME"
) catch "(not set)";
defer std.heap.wasm_allocator.free(home);
try stdout.writer().print("HOME = {s}\n", .{home});
try stdout.writer().print("Hello from Zig WASI!\n", .{});
}
Running with Wasmtime
# Build
zig build -Doptimize=ReleaseFast
# Run (Wasmtime handles the WASI imports automatically)
wasmtime zig-out/bin/cli_tool.wasm
# With environment variables:
wasmtime --env HOME=/tmp zig-out/bin/cli_tool.wasm
# With filesystem access (WASI requires explicit capability grants):
wasmtime --dir /tmp zig-out/bin/cli_tool.wasm
Cloudflare Workers
Cloudflare's Workers runtime supports WASI via the @cloudflare/workers-wasi package:
// worker.js
import { WASI } from "@cloudflare/workers-wasi";
import appWasm from "./app.wasm";
export default {
async fetch(request, env) {
const wasi = new WASI({ env: { REQUEST_URL: request.url } });
const module = await WebAssembly.instantiate(appWasm, wasi.getImportObject());
wasi.start(module.instance);
return new Response("Done");
},
};
23. Advanced: SIMD with @Vector
Zig's @Vector type compiles directly to WASM SIMD128 instructions when the target includes the simd128 feature. This provides 4× throughput on 32-bit operations and 2× on 64-bit.
Build.zig changes for SIMD
const target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .freestanding,
.cpu_features_add = std.Target.wasm.featureSet(&.{
.simd128,
.bulk_memory,
}),
});
Vectorized grayscale
The standard scalar grayscale loop processes 1 pixel per iteration. With @Vector(4, u32) we process 4 pixels simultaneously:
// 0.16.0 NOTE: @Vector uses the new constraint rules for in-memory coercion.
// Arrays and vectors are no longer interchangeable by in-memory cast.
// Use @as() or explicit element-wise assignment instead.
export fn filter_grayscale_simd(pixel_ptr: [*]u8, len: usize) void {
// Process 4 pixels (16 bytes) at a time.
var i: usize = 0;
const end4 = (len / 16) * 16;
while (i < end4) : (i += 16) {
// Load 16 consecutive bytes as 4-element vectors of u32.
// Each RGBA pixel occupies 4 bytes.
const r = @Vector(4, u32){
pixel_ptr[i], pixel_ptr[i + 4],
pixel_ptr[i + 8], pixel_ptr[i + 12],
};
const g = @Vector(4, u32){
pixel_ptr[i + 1], pixel_ptr[i + 5],
pixel_ptr[i + 9], pixel_ptr[i + 13],
};
const b = @Vector(4, u32){
pixel_ptr[i + 2], pixel_ptr[i + 6],
pixel_ptr[i + 10], pixel_ptr[i + 14],
};
// BT.601: luma = (77R + 150G + 29B) >> 8 for each of the 4 pixels.
const luma_v = (r * @as(@Vector(4, u32), @splat(77)) +
g * @as(@Vector(4, u32), @splat(150)) +
b * @as(@Vector(4, u32), @splat(29))) >>
@as(@Vector(4, u32), @splat(8));
// Write luma back to R, G, B channels.
inline for (0..4) |p| {
const luma: u8 = @intCast(luma_v[p]);
pixel_ptr[i + p * 4] = luma;
pixel_ptr[i + p * 4 + 1] = luma;
pixel_ptr[i + p * 4 + 2] = luma;
}
}
// Handle remaining pixels scalar.
while (i + 3 < len) : (i += 4) {
const rv: u32 = pixel_ptr[i];
const gv: u32 = pixel_ptr[i + 1];
const bv: u32 = pixel_ptr[i + 2];
const luma: u8 = @intCast((rv * 77 + gv * 150 + bv * 29) >> 8);
pixel_ptr[i] = luma;
pixel_ptr[i + 1] = luma;
pixel_ptr[i + 2] = luma;
}
}
The browser's WASM JIT will lower @Vector(4, u32) operations to i32x4.mul, i32x4.add, and i32x4.shr_u — native 128-bit SIMD instructions. On a modern CPU this runs approximately 3–4× faster than the scalar version for large images.
Dot product with @Vector
/// Dot product of two f32 arrays using 4-wide SIMD.
export fn dot_f32(a_ptr: [*]const f32, b_ptr: [*]const f32, len: usize) f32 {
var acc = @as(@Vector(4, f32), @splat(0.0));
const end4 = (len / 4) * 4;
var i: usize = 0;
while (i < end4) : (i += 4) {
const a_v: @Vector(4, f32) = .{ a_ptr[i], a_ptr[i+1], a_ptr[i+2], a_ptr[i+3] };
const b_v: @Vector(4, f32) = .{ b_ptr[i], b_ptr[i+1], b_ptr[i+2], b_ptr[i+3] };
acc += a_v * b_v;
}
// Reduce the 4 accumulator lanes.
var result: f32 = acc[0] + acc[1] + acc[2] + acc[3];
// Handle tail.
while (i < len) : (i += 1) result += a_ptr[i] * b_ptr[i];
return result;
}
24. Common Pitfalls and How to Fix Them
1. "exported function not found in instance.exports"
Cause: Dead-code elimination removed the function.
Fix: Ensure exe.rdynamic = true in build.zig, or add the function name to wasm_module.export_symbol_names.
2. "std/Io/Threaded.zig: struct has no member named 'getrandom'"
Cause: You're compiling for wasm32-freestanding but something in your code pulled in std.Io.Threaded.
Fix: Add pub const std_options_debug_io: std.Io = std.Io.failing; at the top level of your main source file.
3. "Invalid typed array length" in JavaScript
Cause: You're accessing a memory offset that's outside the current allocated pages.
Fix: Check that your WASM module's initial_memory is large enough, or ensure you're calling an export that allocates memory before accessing the buffer.
4. Typed array view becomes detached
Cause: WASM memory grew (via memory.grow or wasm_allocator), invalidating the old memory.buffer reference.
Fix: Never cache a reference to memory.buffer. Always re-read it from instance.exports.memory.buffer before each use:
// Wrong:
const view = new Uint8Array(instance.exports.memory.buffer);
instance.exports.do_something_that_may_grow_memory();
view[0]; // detached! runtime error
// Correct:
instance.exports.do_something_that_may_grow_memory();
const view = new Uint8Array(instance.exports.memory.buffer); // re-read
view[0]; // safe
5. @Type compile error (0.16.0 migration)
Cause: Using @Type(.{ ... }) which is removed in 0.16.0.
Fix: Replace with the specific builtin: @Int, @Struct, @Union, @Enum, @Pointer, @Fn, @Tuple.
6. Module instantiation fails with "LinkError: import object field not found"
Cause: Your Zig code declares extern "env" fn foo() but JavaScript's import object doesn't have { env: { foo: ... } }.
Fix: Every extern "env" fn must have a matching entry in the JS import object, even if it's a stub.
7. Struct return type in exported function
Cause: Trying to export fn get_result() SomeStruct.
Fix: Store the struct in a global variable and return a pointer ([*]const SomeStruct), then read individual fields from JavaScript using DataView.
8. f64 reads misaligned in JavaScript DataView
Cause: DataView.getFloat64(offset, true) requires offset to be a byte offset, but you're computing it wrong.
Fix: Remember that f64 values are 8 bytes. To access the n-th f64 field starting at base: offset = base + n * 8. Use DataView rather than typed arrays for struct access so alignment is explicit.
9. std.mem.indexOf compile error (0.16.0 migration)
Cause: std.mem.indexOf was renamed to std.mem.find in 0.16.0.
Fix: Update all call sites. The full rename list is in Section 6.
10. Binary too large for production
Cause: Building with Debug mode, or forgetting wasm-opt.
Fix: Build with -Doptimize=ReleaseSmall then run wasm-opt -Oz. A typical math library drops from 48 KB to under 2 KB.
25. What Comes Next
WASM Component Model
The Component Model is the next generation of WASM interop. Instead of the manual pointer+length protocol shown throughout this post, you define interfaces in WIT (WebAssembly Interface Types):
// math.wit
package local:math;
interface math {
factorial: func(n: u32) -> u64;
gcd: func(a: u32, b: u32) -> u32;
}
world calculator {
export math;
}
The wit-bindgen tool generates Zig binding code automatically. As of April 2026, Zig backend support for wit-bindgen is in active development at the Bytecode Alliance. Follow progress at github.com/bytecodealliance/wit-bindgen.
Zig's Async I/O and WASM
Zig 0.16.0's new std.Io interface is designed to be swappable. For WASM, a future std.Io.Wasi implementation using WASI preview 2's async event model is planned. This would allow Zig code that uses std.Io natively to run in WASM environments without modification.
Threads in WASM
wasm32-freestanding with atomics and bulk_memory features supports shared linear memory across Web Workers. Each worker instantiates the same module but shares a SharedArrayBuffer as the memory. Zig's std.Thread is not yet available in this context (it requires OS support), but low-level @atomicLoad, @atomicStore, and @atomicRmw builtins work directly on shared memory addresses.
The Garbage Collection Proposal
The WASM GC proposal adds managed object types to the spec. Once widely adopted, it will make passing strings, arrays, and objects between WASM and JavaScript much simpler — eliminating most of the pointer+length boilerplate this post teaches. Zig, being a systems language with no GC, will likely use this for interop only, not for its own memory management.
Summary Reference
| Task | Zig 0.16.0 API |
|---|---|
| Target for browser | b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding }) |
| Target for WASI | b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .wasi }) |
| Add SIMD feature | .cpu_features_add = std.Target.wasm.featureSet(&.{ .simd128 }) |
| Create executable | b.addExecutable(.{ .name = "app", .root_module = b.createModule(...) }) |
| Disable entry point | exe.entry = .disabled |
| Keep exported symbols | exe.rdynamic = true |
| Export a function | export fn name(...) T { ... } |
| Import from JS | extern "env" fn name(...) T; |
| Prevent Io.Threaded pull-in | pub const std_options_debug_io: std.Io = std.Io.failing; |
| Format to buffer | std.fmt.bufPrint(&buf, "{d}", .{value}) |
| Fixed-buffer writer | std.Io.Writer.fixed(&buf) |
| General allocator | std.heap.wasm_allocator |
| Bounded allocator | std.heap.FixedBufferAllocator.init(&static_buf) |
| Read WASM text format | wasm2wat file.wasm |
| Size-optimize binary | wasm-opt -Oz file.wasm -o file.wasm |
| Type-creating builtins | @Int, @Struct, @Union, @Enum, @Pointer (replaces @Type) |
| Find in slice (0.16.0) | std.mem.find, std.mem.findScalar (renamed from indexOf*) |
| Split around delimiter | std.mem.cut, std.mem.cutPrefix (new in 0.16.0) |