WebAssembly, often shortened to Wasm, is a portable binary instruction format. It lets you compile code once and run it inside different hosts, such as web browsers, servers,...
WebAssembly, often shortened to Wasm, is a portable binary instruction format. It lets you compile code once and run it inside different hosts, such as web browsers, servers, embedded runtimes, and command-line Wasm engines.
Zig can compile to WebAssembly. This makes Zig useful for small browser modules, plugin systems, sandboxed code, portable command-line logic, games, interpreters, and compute-heavy functions that need predictable low-level control.
A normal Zig program has a main function:
const std = @import("std");
pub fn main() void {
std.debug.print("Hello\n", .{});
}A WebAssembly module often exports functions instead:
export fn add(a: i32, b: i32) i32 {
return a + b;
}Build it:
zig build-lib add.zig -target wasm32-freestanding -dynamic -rdynamicThis produces a .wasm file.
What WebAssembly Is
WebAssembly is not JavaScript. It is also not a full operating system. It is a compact binary format for code.
A Wasm module usually contains:
compiled functions
linear memory
imports
exports
tables
globalsThe host loads the module, provides any imports it needs, and calls its exported functions.
For example, your Zig code may export this:
export fn square(x: i32) i32 {
return x * x;
}Then JavaScript, a server runtime, or another host can call square.
WebAssembly Has a Host
A normal native program runs directly on an operating system.
A WebAssembly module runs inside a host.
Examples of hosts include:
web browser
Wasmtime
Wasmer
WasmEdge
Node.js
Deno
custom application runtimeThis matters because Wasm does not automatically have access to files, sockets, clocks, environment variables, or the terminal. The host decides what the module can do.
This is one reason WebAssembly is useful for sandboxing. A module can be given only the capabilities the host wants to provide.
wasm32-freestanding
A common Zig target for simple Wasm modules is:
wasm32-freestandingThe wasm32 part means 32-bit WebAssembly.
The freestanding part means there is no normal operating system interface.
This target is good for small exported functions:
export fn multiply(a: i32, b: i32) i32 {
return a * b;
}Build:
zig build-lib math.zig -target wasm32-freestanding -dynamic -rdynamicThe result is a Wasm module that exports functions.
Exported Functions
A function must be exported if the host needs to call it.
export fn add(a: i32, b: i32) i32 {
return a + b;
}The export keyword makes the function visible outside the module.
Keep exported function signatures simple at first. Use integers and floats before trying strings, slices, structs, or pointers.
Good beginner exports:
export fn add(a: i32, b: i32) i32 {
return a + b;
}
export fn is_even(x: i32) bool {
return x % 2 == 0;
}More advanced exports need memory coordination between Zig and the host.
WebAssembly Memory
WebAssembly uses linear memory. You can think of it as one large byte array.
A pointer in Wasm is usually an offset into this memory.
For example, a Zig slice:
[]const u8cannot be passed directly to JavaScript as a normal JavaScript string. The host needs to know where the bytes are and how many bytes to read.
That usually means passing two values:
pointer
lengthA common pattern is:
export fn get_message_ptr() [*]const u8 {
return "hello".ptr;
}
export fn get_message_len() usize {
return "hello".len;
}The host calls both functions, then reads bytes from Wasm memory.
This is more manual than calling a normal native function, but it is also explicit.
Strings Are Bytes
In Zig, string literals are UTF-8 bytes.
const msg = "hello";In WebAssembly, the host must read those bytes from module memory and decode them if it wants a host-language string.
That means string exchange usually needs three pieces:
memory export
pointer
lengthFor beginners, avoid passing strings across the Wasm boundary until you understand memory exports.
Start with numbers. Then move to buffers.
Allocating Memory for the Host
Sometimes the host needs to pass data into the Zig module. For that, the module can export allocation functions.
Example:
const std = @import("std");
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
export fn alloc(len: usize) ?[*]u8 {
const buf = allocator.alloc(u8, len) catch return null;
return buf.ptr;
}
export fn free(ptr: [*]u8, len: usize) void {
allocator.free(ptr[0..len]);
}The host can call alloc, write bytes into Wasm memory, call another exported function, then call free.
This is a common pattern, but it requires discipline. The host and module must agree on ownership.
The rule is simple: whoever allocates should usually provide the matching free function.
No Normal Files by Default
On native Linux, macOS, or Windows, this kind of code can open a file:
const file = try std.fs.cwd().openFile("data.txt", .{});On wasm32-freestanding, there is no normal filesystem.
That does not mean Wasm can never use files. It means the host must provide file access through imports or through a system interface such as WASI.
WASI
WASI stands for WebAssembly System Interface.
It defines a standard way for WebAssembly modules to access system-like features, such as files, clocks, random data, and environment variables, when the host allows them.
A target may look like:
wasm32-wasiUse WASI when you want a WebAssembly module that behaves more like a small command-line program.
Use freestanding Wasm when you want a minimal module with explicit imports and exports.
A useful mental split:
wasm32-freestanding: small module, no OS assumptions
wasm32-wasi: portable system interface, more like a CLI programCalling Wasm from JavaScript
Suppose you have this Zig file:
export fn add(a: i32, b: i32) i32 {
return a + b;
}Build:
zig build-lib add.zig -target wasm32-freestanding -dynamic -rdynamicA JavaScript host can load the module and call it:
const bytes = await fetch("add.wasm").then((res) => res.arrayBuffer());
const module = await WebAssembly.instantiate(bytes);
console.log(module.instance.exports.add(20, 22));Output:
42This is the simplest kind of Zig-Wasm boundary: numbers in, number out.
Calling Wasm from a Runtime
Browsers are not the only Wasm hosts.
With a runtime such as Wasmtime, you can run Wasm from the command line or embed it in a server application.
The same core idea applies:
load module
provide imports
call exports
read memory if neededWasm is a portable execution format. The details depend on the host.
Limitations
WebAssembly is powerful, but it has limits.
You do not automatically get threads, sockets, files, or operating system APIs.
You do not pass complex Zig data structures across the boundary directly.
You must design the interface between the host and the module.
Debugging can be different from native debugging.
Performance is good for many compute-heavy tasks, but crossing the host-module boundary too often can be expensive.
Because of this, Wasm works best when the interface is small and clear.
Good use:
parse this buffer
compress this data
run this algorithm
validate this input
simulate this step
render this framePoor use:
call into Wasm for every tiny object property
move many small strings back and forth
depend on hidden host behaviorDesigning a Good Wasm API
A good Wasm API is usually coarse-grained.
Instead of this:
call add_byte one million timesPrefer this:
pass one buffer
process the whole buffer
return one resultThat reduces boundary overhead.
Use simple exported functions:
export fn process(ptr: [*]u8, len: usize) usize {
const data = ptr[0..len];
var count: usize = 0;
for (data) |byte| {
if (byte == '\n') count += 1;
}
return count;
}This function receives a pointer and length, views them as a slice, counts newline bytes, and returns the count.
The host is responsible for putting the data into Wasm memory before calling process.
Complete Example
Here is a small Zig Wasm module:
export fn add(a: i32, b: i32) i32 {
return a + b;
}
export fn square(x: i32) i32 {
return x * x;
}
export fn max(a: i32, b: i32) i32 {
if (a > b) return a;
return b;
}Save it as math.zig.
Build it:
zig build-lib math.zig -target wasm32-freestanding -dynamic -rdynamicThis creates a Wasm module.
A JavaScript host can call it:
const bytes = await fetch("math.wasm").then((res) => res.arrayBuffer());
const wasm = await WebAssembly.instantiate(bytes);
const api = wasm.instance.exports;
console.log(api.add(2, 3));
console.log(api.square(9));
console.log(api.max(10, 4));Possible output:
5
81
10This example avoids strings, files, allocators, and complex memory rules. That is the right starting point.
The Practical View
WebAssembly is best understood as a small, portable execution target. Zig compiles well to it because Zig already makes memory, types, exports, and platform boundaries explicit.
Start with pure functions that take numbers and return numbers. Then learn linear memory. Then learn pointer-plus-length buffer passing. After that, learn allocation, WASI, host imports, and browser or runtime integration.
For Zig beginners, WebAssembly teaches an important lesson: when there is no normal operating system underneath your program, every dependency must be made explicit.