Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

zwasm

A standalone WebAssembly runtime written in Zig. Runs Wasm modules as a CLI tool or embeds as a Zig library.

Features

  • Full Wasm 3.0 support: Core spec + 9 proposals (GC, exception handling, tail calls, SIMD, threads, and more)
  • 62,263 spec tests passing: 100% on macOS ARM64 and Linux x86_64
  • 4-tier execution: Interpreter with register IR and ARM64/x86_64 JIT compilation
  • WASI Preview 1: 46 syscalls with deny-by-default capability model
  • Small footprint: ~1.2 MB binary, ~3.5 MB runtime memory
  • Library and CLI: Use as a zig build dependency or run modules from the command line
  • WAT support: Run .wat text format files directly

Quick Start

# Run a WebAssembly module
zwasm hello.wasm

# Invoke a specific function
zwasm math.wasm --invoke add 2 3

# Run a WAT text file
zwasm program.wat

See Getting Started for installation instructions.

Getting Started

This guide gets you from zero to running a WebAssembly module in under 5 minutes.

Prerequisites

Install

Build from source

git clone https://github.com/clojurewasm/zwasm.git
cd zwasm
zig build -Doptimize=ReleaseSafe

The binary is at zig-out/bin/zwasm. Copy it to your PATH:

cp zig-out/bin/zwasm ~/.local/bin/

Install script

curl -fsSL https://raw.githubusercontent.com/clojurewasm/zwasm/main/install.sh | bash

Homebrew (macOS/Linux) — coming soon

brew install clojurewasm/tap/zwasm  # not yet available

Verify installation

zwasm version

Run your first module

1. From a WAT file

Create hello.wat:

(module
  (func (export "add") (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add))

Run it:

zwasm hello.wat --invoke add 2 3
# Output: 5

2. WASI module

For modules that use WASI (filesystem, stdout, etc.):

zwasm hello_wasi.wasm --allow-all

Grant only the capabilities you need:

zwasm hello_wasi.wasm --allow-read --dir ./data

3. Inspect a module

See what a module exports and imports:

zwasm inspect hello.wasm

4. Validate a module

Check if a module is valid without running it:

zwasm validate hello.wasm

Use as a Zig library

Add zwasm as a dependency in your build.zig.zon:

.dependencies = .{
    .zwasm = .{
        .url = "https://github.com/clojurewasm/zwasm/archive/refs/tags/v1.1.0.tar.gz",
        .hash = "...",  // zig build will tell you the correct hash
    },
},

Then in build.zig:

const zwasm_dep = b.dependency("zwasm", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("zwasm", zwasm_dep.module("zwasm"));

See the Embedding Guide for API usage.

More examples

The repository includes 33 numbered WAT examples in examples/wat/, ordered from beginner to advanced:

zwasm examples/wat/01_hello_add.wat --invoke add 2 3      # basics
zwasm examples/wat/02_if_else.wat --invoke abs -7          # if/else
zwasm examples/wat/03_loop.wat --invoke sum 100            # loops → 5050
zwasm examples/wat/05_fibonacci.wat --invoke fib 10        # recursion → 55
zwasm examples/wat/24_call_indirect.wat --invoke apply 0 10 3  # tables → 13
zwasm examples/wat/25_return_call.wat --invoke sum 1000000 # tail calls
zwasm examples/wat/30_wasi_hello.wat --allow-all           # WASI → Hi!
zwasm examples/wat/32_wasi_args.wat --allow-all -- hi      # WASI args

Each file includes run instructions in its header comment. Zig embedding examples are in examples/zig/.

Next steps

CLI Reference

Commands

zwasm run / zwasm <file>

Execute a WebAssembly module. The run subcommand is optional — zwasm file.wasm is equivalent to zwasm run file.wasm.

zwasm <file.wasm|.wat> [options] [args...]
zwasm run <file.wasm|.wat> [options] [args...]

By default, calls _start (WASI entry point). Use --invoke to call a specific exported function.

Examples:

# Run a WASI module (calls _start)
zwasm hello.wasm --allow-all

# Run a WAT text format file (no compilation needed)
zwasm program.wat

# Call a specific exported function
zwasm math.wasm --invoke add 2 3

Argument types

Function arguments are type-aware: zwasm uses the function’s type signature to parse integers, floats, and negative numbers correctly.

# Integers
zwasm math.wat --invoke add 2 3          # → 5

# Negative numbers (no -- needed)
zwasm math.wat --invoke negate -5        # → -5
zwasm math.wat --invoke abs -42          # → 42

# Floating-point
zwasm math.wat --invoke double 3.14      # → 6.28
zwasm math.wat --invoke half -6.28       # → -3.14

# 64-bit integers
zwasm math.wat --invoke fib 50           # → 12586269025

Results are displayed in their natural format:

  • i32/i64: signed decimal (e.g. -1, not 4294967295)
  • f32/f64: decimal (e.g. 3.14, not raw bits)

Argument count is validated against the function signature:

zwasm math.wat --invoke add 2             # error: 'add' expects 2 arguments, got 1

WASI modules

WASI modules use _start and receive string arguments via args_get. Use -- to separate WASI args from zwasm options:

# String args passed to the WASI module
zwasm app.wasm --allow-all -- hello world
zwasm app.wasm --allow-read --dir ./data -- input.txt

# Environment variables (injected vars accessible without --allow-env)
zwasm app.wasm --env HOME=/tmp --env USER=alice

# Sandbox mode: deny all + fuel 1B + memory 256MB
zwasm untrusted.wasm --sandbox
zwasm untrusted.wasm --sandbox --allow-read --dir ./data

Multi-module linking

# Link an import module and call a function
zwasm app.wasm --link math=math.wasm --invoke compute 42

Resource limits

# Limit instructions (fuel metering) and memory
zwasm untrusted.wasm --fuel 1000000 --max-memory 16777216

zwasm inspect

Show a module’s imports and exports.

zwasm inspect [--json] <file.wasm|.wat>
# Human-readable
zwasm inspect examples/wat/01_hello_add.wat

# JSON output (for scripting)
zwasm inspect --json math.wasm

Options:

  • --json — Output in JSON format

zwasm validate

Check if a module is valid without executing it.

zwasm validate <file.wasm|.wat>

zwasm features

List supported WebAssembly proposals.

zwasm features [--json]

zwasm version

Print the version string.

zwasm help

Show usage information.

Run options

Execution

FlagDescription
--invoke <func>Call <func> instead of _start
--batchBatch mode: read invocations from stdin
--link name=fileLink a module as import source (repeatable)

WASI capabilities

FlagDescription
--sandboxDeny all capabilities + fuel 1B + memory 256MB
--allow-allGrant all WASI capabilities
--allow-readGrant filesystem read
--allow-writeGrant filesystem write
--allow-envGrant environment variable access
--allow-pathGrant path operations (open, mkdir, unlink)
--dir <path>Preopen a host directory (repeatable)
--env KEY=VALUESet a WASI environment variable (always accessible)

Resource limits

FlagDescription
--max-memory <N>Memory ceiling in bytes (limits memory.grow)
--fuel <N>Instruction fuel limit (traps when exhausted)

Debugging

FlagDescription
--profilePrint execution profile (opcode frequency, call counts)
--trace=CATSTrace categories: jit,regir,exec,mem,call (comma-separated)
--dump-regir=NDump register IR for function index N
--dump-jit=NDump JIT disassembly for function index N

Batch mode

With --batch, zwasm reads invocation commands from stdin, one per line:

add 2 3
mul 4 5
fib 10
echo -e "add 2 3\nmul 4 5" | zwasm math.wasm --batch --invoke add

Exit codes

CodeMeaning
0Success
1Runtime error (trap, stack overflow, etc.)
2Invalid module or validation error
126File not found

Embedding Guide

Use zwasm as a Zig library to load and execute WebAssembly modules in your application.

Setup

Add zwasm to your build.zig.zon:

.dependencies = .{
    .zwasm = .{
        .url = "https://github.com/clojurewasm/zwasm/archive/refs/tags/v1.1.0.tar.gz",
        .hash = "...",  // zig build will provide the correct hash
    },
},

In build.zig:

const zwasm_dep = b.dependency("zwasm", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("zwasm", zwasm_dep.module("zwasm"));

Basic usage

const zwasm = @import("zwasm");
const WasmModule = zwasm.WasmModule;

// Load a module from bytes
const mod = try WasmModule.load(allocator, wasm_bytes);
defer mod.deinit();

// Call an exported function
var args = [_]u64{ 10, 20 };
var results = [_]u64{0};
try mod.invoke("add", &args, &results);

const sum: i32 = @bitCast(@as(u32, @truncate(results[0])));

Loading variants

MethodUse case
load(alloc, bytes)Basic module, no WASI
loadFromWat(alloc, wat_src)Load from WAT text format
loadWasi(alloc, bytes)Module with WASI (cli_default caps)
loadWasiWithOptions(alloc, bytes, opts)WASI with custom config
loadWithImports(alloc, bytes, imports)Module with host functions
loadWasiWithImports(alloc, bytes, imports, opts)Both WASI and host functions
loadWithFuel(alloc, bytes, fuel)With instruction fuel limit

Host functions

Provide native Zig functions as Wasm imports:

const zwasm = @import("zwasm");

fn hostLog(ctx_ptr: *anyopaque, context: usize) anyerror!void {
    _ = context;
    const vm: *zwasm.Vm = @ptrCast(@alignCast(ctx_ptr));

    // Pop argument from operand stack
    const value = vm.popOperandI32();
    std.debug.print("log: {}\n", .{value});

    // Push return value (if function returns one)
    // try vm.pushOperand(@bitCast(@as(i32, result)));
}

const imports = [_]zwasm.ImportEntry{
    .{
        .module = "env",
        .source = .{ .host_fns = &.{
            .{ .name = "log", .callback = hostLog, .context = 0 },
        }},
    },
};

const mod = try WasmModule.loadWithImports(allocator, wasm_bytes, &imports);

WASI configuration

// loadWasi() defaults to cli_default caps (stdio, clock, random, proc_exit).
// Use loadWasiWithOptions for full access or custom capabilities:
const opts = zwasm.WasiOptions{
    .args = &.{ "my-app", "--verbose" },
    .env_keys = &.{"HOME"},
    .env_vals = &.{"/tmp"},
    .preopen_paths = &.{"./data"},
    .caps = zwasm.Capabilities.all,
};

const mod = try WasmModule.loadWasiWithOptions(allocator, wasm_bytes, opts);

Memory access

Read from and write to the module’s linear memory:

// Read 100 bytes starting at offset 0
const data = try mod.memoryRead(allocator, 0, 100);
defer allocator.free(data);

// Write data at offset 256
try mod.memoryWrite(256, &.{ 0x48, 0x65, 0x6C, 0x6C, 0x6F });

Module linking

Link multiple modules together:

// Load the "math" module and register its exports
const math_mod = try WasmModule.load(allocator, math_bytes);
defer math_mod.deinit();
try math_mod.registerExports("math");

// Load another module that imports from "math"
const imports = [_]zwasm.ImportEntry{
    .{ .module = "math", .source = .{ .wasm_module = math_mod } },
};
const app_mod = try WasmModule.loadWithImports(allocator, app_bytes, &imports);
defer app_mod.deinit();

Inspecting imports

Check what a module needs before instantiation:

const import_infos = try zwasm.inspectImportFunctions(allocator, wasm_bytes);
defer allocator.free(import_infos);

for (import_infos) |info| {
    std.debug.print("{s}.{s}: {d} params, {d} results\n", .{
        info.module, info.name, info.param_count, info.result_count,
    });
}

Resource limits

Control resource usage:

// Fuel limit: traps after N instructions
const mod = try WasmModule.loadWithFuel(allocator, wasm_bytes, 1_000_000);

// Memory limit: via WASI options or direct Vm access

Error handling

All loading and execution methods return error unions. Key error types:

  • error.InvalidWasm — Binary format is invalid
  • error.ImportNotFound — Required import not provided
  • error.Trap — Unreachable instruction executed
  • error.StackOverflow — Call depth exceeded 1024
  • error.OutOfBoundsMemoryAccess — Memory access out of bounds
  • error.OutOfMemory — Allocator failed
  • error.FuelExhausted — Instruction fuel limit hit

See Error Reference for the complete list.

Allocator control

zwasm takes a std.mem.Allocator at load time and uses it for all internal allocations. You control the allocator:

// Use the general purpose allocator
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const mod = try WasmModule.load(gpa.allocator(), wasm_bytes);

// Or use an arena for batch cleanup
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const mod = try WasmModule.load(arena.allocator(), wasm_bytes);

API stability

Types and functions are classified into three stability levels:

  • Stable: Covered by SemVer. Will not break in minor/patch releases. Includes: WasmModule, WasmFn, WasmValType, ExportInfo, ImportEntry, HostFnEntry, WasiOptions, and all their public methods.

  • Experimental: May change in minor releases. Includes: runtime.Store, runtime.Module, runtime.Instance, loadLinked, WIT-related functions.

  • Internal: Not accessible to library consumers. All types in source files other than types.zig.

See docs/api-boundary.md for the complete list.

Using a non-Zig language? zwasm also exposes a C API (libzwasm) that works with any FFI-capable language — C, Python, Rust, Go, and more. See C API & Cross-Language Integration.

C API & Cross-Language Integration

The Embedding Guide showed how to use zwasm from Zig. But zwasm is also a C library — any language with a C FFI can load and run WebAssembly modules through it. This chapter covers the C API: building the shared library, calling it from C and Python, and working with host functions, WASI, and memory.

Building the library

zig build lib                              # Build libzwasm (.dylib / .so / .a)
zig build lib -Doptimize=ReleaseSafe       # Optimized build

This produces:

OutputPath
Shared libraryzig-out/lib/libzwasm.dylib (macOS) or libzwasm.so (Linux)
Static libraryzig-out/lib/libzwasm.a
C headerinclude/zwasm.h

The header file include/zwasm.h is the single source of truth for the C API. All types are opaque pointers; all functions use the zwasm_ prefix.

Quickstart: C

Load a module, invoke an exported function, and read the result:

#include <stdio.h>
#include "zwasm.h"

/* Wasm module: (func (export "f") (result i32) (i32.const 42)) */
static const uint8_t WASM[] = {
    0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
    0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f,
    0x03, 0x02, 0x01, 0x00,
    0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00,
    0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b
};

int main(void) {
    zwasm_module_t *mod = zwasm_module_new(WASM, sizeof(WASM));
    if (!mod) {
        fprintf(stderr, "Error: %s\n", zwasm_last_error_message());
        return 1;
    }

    uint64_t results[1] = {0};
    if (!zwasm_module_invoke(mod, "f", NULL, 0, results, 1)) {
        fprintf(stderr, "Invoke error: %s\n", zwasm_last_error_message());
        zwasm_module_delete(mod);
        return 1;
    }

    printf("f() = %llu\n", (unsigned long long)results[0]);

    zwasm_module_delete(mod);
    return 0;
}

Build and run:

zig build lib && zig build c-test
./zig-out/bin/example_c_hello
# f() = 42

Quickstart: Python (ctypes)

The same workflow using Python’s built-in ctypes module — no compiled bindings required:

import ctypes, os

lib = ctypes.CDLL("zig-out/lib/libzwasm.dylib")  # or .so on Linux

# Declare function signatures
lib.zwasm_module_new.argtypes = [ctypes.c_char_p, ctypes.c_size_t]
lib.zwasm_module_new.restype = ctypes.c_void_p
lib.zwasm_module_delete.argtypes = [ctypes.c_void_p]
lib.zwasm_module_delete.restype = None
lib.zwasm_module_invoke.argtypes = [
    ctypes.c_void_p, ctypes.c_char_p,
    ctypes.POINTER(ctypes.c_uint64), ctypes.c_uint32,
    ctypes.POINTER(ctypes.c_uint64), ctypes.c_uint32,
]
lib.zwasm_module_invoke.restype = ctypes.c_bool
lib.zwasm_last_error_message.argtypes = []
lib.zwasm_last_error_message.restype = ctypes.c_char_p

# Same Wasm bytes as the C example
wasm = bytes([
    0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
    0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f,
    0x03, 0x02, 0x01, 0x00,
    0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00,
    0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b,
])

mod = lib.zwasm_module_new(wasm, len(wasm))
assert mod, f"Error: {lib.zwasm_last_error_message().decode()}"

results = (ctypes.c_uint64 * 1)(0)
ok = lib.zwasm_module_invoke(mod, b"f", None, 0, results, 1)
assert ok, f"Invoke error: {lib.zwasm_last_error_message().decode()}"

print(f"f() = {results[0]}")  # f() = 42

lib.zwasm_module_delete(mod)

Run:

zig build lib
python3 examples/python/basic.py

Quickstart: Rust (FFI)

Rust can call the same C API via extern "C" bindings:

#![allow(unused)]
fn main() {
#[link(name = "zwasm")]
unsafe extern "C" {
    fn zwasm_module_new(wasm_ptr: *const u8, len: usize) -> *mut zwasm_module_t;
    fn zwasm_module_invoke(
        module: *mut zwasm_module_t, name: *const std::ffi::c_char,
        args: *const u64, nargs: u32, results: *mut u64, nresults: u32,
    ) -> bool;
    fn zwasm_module_delete(module: *mut zwasm_module_t);
}
}

Build and run (requires Rust 1.85+ for edition 2024):

zig build shared-lib
cd examples/rust && cargo run
# f() = 42

See examples/rust/ for the full working example.

API reference

Functions are grouped by domain. All signatures live in include/zwasm.h.

Error handling

FunctionDescription
zwasm_last_error_message()Last error as a null-terminated string. Returns "" if no error. Thread-local.

Module lifecycle

FunctionDescription
zwasm_module_new(wasm_ptr, len)Create module from binary bytes. Returns NULL on error.
zwasm_module_new_wasi(wasm_ptr, len)Create WASI module with default capabilities.
zwasm_module_new_wasi_configured(wasm_ptr, len, config)Create WASI module with custom config.
zwasm_module_new_with_imports(wasm_ptr, len, imports)Create module with host function imports.
zwasm_module_delete(module)Free all module resources.
zwasm_module_validate(wasm_ptr, len)Validate binary without instantiation.

Function invocation

FunctionDescription
zwasm_module_invoke(module, name, args, nargs, results, nresults)Invoke an exported function by name.
zwasm_module_invoke_start(module)Invoke _start (WASI entry point).

Export introspection

FunctionDescription
zwasm_module_export_count(module)Number of exported functions.
zwasm_module_export_name(module, idx)Name of the idx-th export.
zwasm_module_export_param_count(module, idx)Parameter count of an export.
zwasm_module_export_result_count(module, idx)Result count of an export.

Memory access

FunctionDescription
zwasm_module_memory_data(module)Direct pointer to linear memory. Invalidated by growth.
zwasm_module_memory_size(module)Current memory size in bytes.
zwasm_module_memory_read(module, offset, len, out_buf)Safe bounded read.
zwasm_module_memory_write(module, offset, data, len)Safe bounded write.

WASI configuration

FunctionDescription
zwasm_wasi_config_new()Create a config handle.
zwasm_wasi_config_delete(config)Free a config handle.
zwasm_wasi_config_set_argv(config, argc, argv)Set command-line arguments.
zwasm_wasi_config_set_env(config, count, keys, key_lens, vals, val_lens)Set environment variables.
zwasm_wasi_config_preopen_dir(config, host_path, host_len, guest_path, guest_len)Map a host directory.

Host function imports

FunctionDescription
zwasm_import_new()Create an import collection.
zwasm_import_delete(imports)Free an import collection.
zwasm_import_add_fn(imports, module, name, callback, env, params, results)Register a host function.

Value encoding

Wasm values are passed as uint64_t arrays. The encoding matches the raw Wasm value representation:

Wasm typeC encodingNotes
i32Zero-extended to uint64_tUpper 32 bits are zero
i64Direct uint64_tNo conversion needed
f32IEEE 754 bits, zero-extendedUse memcpy to a float, not a cast
f64IEEE 754 bits as uint64_tUse memcpy to a double, not a cast

Example — passing an f64 argument:

double val = 3.14;
uint64_t arg;
memcpy(&arg, &val, sizeof(arg));

uint64_t result[1];
zwasm_module_invoke(mod, "sqrt", &arg, 1, result, 1);

double out;
memcpy(&out, &result[0], sizeof(out));

Host functions

A host function is a C callback that the Wasm module can call as an import.

Callback signature:

typedef bool (*zwasm_host_fn_callback_t)(
    void *env,              /* User context pointer */
    const uint64_t *args,   /* Input parameters */
    uint64_t *results       /* Output buffer */
);

Working example — a print_i32 host function:

#include <stdio.h>
#include "zwasm.h"

static bool print_i32(void *env, const uint64_t *args, uint64_t *results) {
    (void)env;
    (void)results;
    printf("wasm says: %d\n", (int32_t)args[0]);
    return true;
}

int main(void) {
    zwasm_imports_t *imports = zwasm_import_new();
    zwasm_import_add_fn(imports, "env", "print_i32", print_i32, NULL, 1, 0);

    zwasm_module_t *mod = zwasm_module_new_with_imports(wasm_bytes, wasm_len, imports);
    /* ... invoke, then cleanup ... */
    zwasm_module_delete(mod);
    zwasm_import_delete(imports);
}

The env pointer lets you pass arbitrary context (a struct, file handle, etc.) to the callback without globals.

WASI programs

Use the config builder pattern to run WASI programs with custom settings:

/* Create and configure WASI */
zwasm_wasi_config_t *config = zwasm_wasi_config_new();

const char *argv[] = {"myapp", "--verbose"};
zwasm_wasi_config_set_argv(config, 2, argv);

zwasm_wasi_config_preopen_dir(config, "/tmp/data", 9, "/data", 5);

/* Create module with WASI config */
zwasm_module_t *mod = zwasm_module_new_wasi_configured(wasm_bytes, wasm_len, config);

/* Run the program */
zwasm_module_invoke_start(mod);

/* Cleanup */
zwasm_module_delete(mod);
zwasm_wasi_config_delete(config);

For simple WASI programs that only need default capabilities (stdio, clock, random):

zwasm_module_t *mod = zwasm_module_new_wasi(wasm_bytes, wasm_len);
zwasm_module_invoke_start(mod);
zwasm_module_delete(mod);

Thread safety

  • Error buffer: zwasm_last_error_message() returns a thread-local buffer. Safe to call from multiple threads.
  • Modules: A zwasm_module_t is not thread-safe. Do not invoke functions on the same module from multiple threads concurrently. Create separate module instances per thread instead.

Next steps

  • Build Configuration — customize which features are compiled in
  • examples/c/, examples/python/, and examples/rust/ — working examples in the repository
  • include/zwasm.h — the complete C header with doc comments

Build Configuration

zwasm builds all features by default. For size-constrained environments — embedded systems, edge functions, minimal containers — you can strip features you do not need at compile time.

Feature flags

Pass flags to zig build to enable or disable features:

FlagDescriptionDefault
-Djit=falseDisable JIT compiler (ARM64/x86_64). Interpreter only.true
-Dcomponent=falseDisable Component Model (WIT, Canon ABI, WASI P2).true
-Dwat=falseDisable WAT text format parser. Binary-only loading.true
-Dsimd=falseDisable SIMD opcodes (v128 operations).true
-Dgc=falseDisable GC proposal (struct/array types).true
-Dthreads=falseDisable threads and atomics.true

Example:

zig build -Doptimize=ReleaseSafe -Djit=false -Dwat=false

Size impact

Measured on Linux x86_64, ReleaseSafe, stripped:

VariantFlagsSize (approx.)Delta
Full (default)(none)~1.23 MB
No JIT-Djit=false~1.03 MB−16%
No Component Model-Dcomponent=false~1.13 MB−8%
No WAT-Dwat=false~1.15 MB−6%
Minimal-Djit=false -Dcomponent=false -Dwat=false~940 KB−24%

The minimal configuration still passes all non-JIT spec tests and supports the full Wasm 3.0 instruction set (interpreted).

Common profiles

Interpreter-only

Smallest binary. Suitable when startup latency matters more than peak throughput:

zig build -Doptimize=ReleaseSafe -Djit=false

Minimal CLI

Strip everything not needed for running core Wasm binaries:

zig build -Doptimize=ReleaseSafe -Djit=false -Dcomponent=false -Dwat=false

Full (default)

All features enabled. Recommended for general use:

zig build -Doptimize=ReleaseSafe

How it works

Feature flags are defined in build.zig as b.option(bool, ...) values, then passed to the Zig module as compile-time options. Source files check them with @import("build_options"):

const build_options = @import("build_options");

if (build_options.enable_jit) {
    // JIT compilation path
} else {
    // Interpreter-only path
}

When a feature is disabled, Zig’s dead code elimination removes all related code from the binary. There is no runtime overhead — disabled features simply do not exist in the output.

Library builds with flags

Feature flags work with the library target too:

# Build a minimal shared library (no JIT, no component model)
zig build lib -Doptimize=ReleaseSafe -Djit=false -Dcomponent=false

The resulting libzwasm.so / .dylib will be smaller but still expose the full C API. Functions that depend on disabled features will return an error when called (e.g., loading a component binary with -Dcomponent=false returns an error via zwasm_last_error_message()).

CI size matrix

The CI pipeline includes a size-matrix job that builds five variants (full, no-jit, no-component, no-wat, minimal) and reports their stripped sizes. This catches unexpected size regressions when new code is added.

See .github/workflows/ci.yml for the full configuration.

FAQ & Troubleshooting

General

What Wasm proposals does zwasm support?

All 9 Wasm 3.0 proposals plus threads, wide arithmetic, and custom page sizes. See Spec Coverage for details.

Does zwasm support Windows?

Not currently. zwasm runs on macOS (ARM64) and Linux (x86_64, aarch64). The JIT and memory guard pages use POSIX APIs (mmap, mprotect, signal handlers).

Can I use zwasm from C, Python, or other languages?

Yes. zwasm provides a C API (libzwasm) that any FFI-capable language can use. Build with zig build lib to produce the shared library, then call zwasm_* functions via your language’s FFI mechanism (e.g., Python ctypes, Rust extern "C", Go cgo). See C API & Cross-Language Integration.

Can I reduce the binary size?

Yes. Use build-time feature flags to strip features you do not need: -Djit=false (no JIT, −16%), -Dcomponent=false (no Component Model, −8%), -Dwat=false (no WAT parser, −6%). Combining all three produces a ~940 KB minimal binary (−24%). See Build Configuration.

Can I use zwasm without JIT?

Yes. The interpreter handles all functions by default. JIT is only triggered for hot functions. To build without JIT entirely, use -Djit=false — this removes the JIT compiler from the binary and reduces its size by ~16%. Functions that are called fewer than ~8 times will always use the interpreter regardless.

What is the WAT parser?

zwasm can run .wat text format files directly: zwasm run program.wat. The WAT parser can be disabled at compile time with -Dwat=false to reduce binary size.

Troubleshooting

“trap: out-of-bounds memory access”

The Wasm module tried to read or write memory outside its linear memory bounds. This is a bug in the Wasm module, not in zwasm. Check that the module’s memory is large enough for its data.

“trap: call stack overflow (depth > 1024)”

Recursive function calls exceeded the 1024 depth limit. This is typically caused by infinite recursion in the Wasm module.

“required import not found”

The module requires an import that was not provided. Use zwasm inspect to see what imports the module needs, then provide them with --link or host functions.

“invalid wasm binary”

The file is not a valid WebAssembly binary. Check that it starts with the magic bytes \0asm and version \01\00\00\00. WAT files should use the .wat extension.

Slow performance

  • Make sure you build with zig build -Doptimize=ReleaseSafe. Debug builds are 5-10x slower.
  • Hot functions (called many times) are JIT-compiled automatically. Short-running programs may not benefit from JIT.
  • Use --profile to see opcode frequency and call counts.

High memory usage

  • Every Wasm module with linear memory allocates guard pages (~4 GiB virtual, not physical). This is normal and shows up as large VSIZE but small RSS.
  • Use --max-memory to cap the actual memory a module can allocate.

Architecture

zwasm processes a WebAssembly module through a multi-stage pipeline: decode, validate, predecode to register IR, and execute via interpreter or JIT.

Pipeline

.wasm binary
      |
      v
+-----------+
|  Decode   |  module.zig -- parse binary format, sections, types
+-----+-----+
      |
      v
+-----------+
| Validate  |  validate.zig -- type checking, operand stack simulation
+-----+-----+
      |
      v
+-----------+
| Predecode |  predecode.zig -- stack machine -> register IR
+-----+-----+
      |
      v
+-----------+
| Regalloc  |  regalloc.zig -- virtual -> physical register assignment
+-----+-----+
      |
      v
+----------------------+
|      Execution       |
|  +----------------+  |
|  |  Interpreter   |  |  vm.zig -- register IR dispatch loop
|  +-------+--------+  |
|          | hot path  |
|  +-------v--------+  |
|  |  JIT Compiler  |  |  jit.zig (ARM64), x86.zig (x86_64)
|  +----------------+  |
+----------------------+

Execution tiers

zwasm uses tiered execution with automatic promotion:

  1. Interpreter — Executes register IR instructions directly. All functions start here.
  2. JIT (ARM64/x86_64) — When a function’s call count or back-edge count exceeds a threshold, the register IR is compiled to native machine code. Subsequent calls execute the native code directly.

The JIT threshold is adaptive: hot loops trigger compilation faster via back-edge counting.

Source map

FileRoleLOC
module.zigBinary decoder, section parsing, LEB128~2.1K
validate.zigType checker, operand stack simulation~2.4K
predecode.zigStack IR → register IR conversion~0.7K
regalloc.zigVirtual → physical register allocation~2.3K
vm.zigInterpreter, execution engine, store~10.2K
jit.zigARM64 JIT backend (incl. NEON SIMD)~7.8K
x86.zigx86_64 JIT backend (incl. SSE SIMD)~7.2K
types.zigCore type definitions, value types~1.4K
opcode.zigOpcode definitions (581+ total)~1.3K
wasi.zigWASI Preview 1 (46 syscalls)~3.1K
gc.zigGC proposal: heap, struct/array types~1.4K
wat.zigWAT text format parser~6.0K
cli.zigCLI frontend~2.3K
instance.zigModule instantiation, linking~0.9K
component.zigComponent Model decoder~1.9K
wit.zigWIT parser~2.1K
canon_abi.zigCanonical ABI~1.2K

Register IR

Instead of interpreting the WebAssembly stack machine directly, zwasm converts each function body to a register-based intermediate representation (IR) during predecode. This eliminates operand stack bookkeeping at runtime:

  • Stack IR: local.get 0 / local.get 1 / i32.add (3 stack operations)
  • Register IR: add r2, r0, r1 (1 instruction)

The register IR uses virtual registers, which are then mapped to physical registers by the register allocator. Functions with few locals map directly; functions with many locals spill to memory.

JIT compilation

The JIT compiler translates register IR to native machine code:

  • ARM64: Full support — arithmetic, control flow, floating point, memory, call_indirect, SIMD (253/256 NEON native)
  • x86_64: Full support — same coverage, SIMD (244/256 SSE native, SSE4.1 minimum)

Key JIT optimizations:

  • Inline self-calls (recursive functions call themselves without trampoline overhead)
  • Smart spill/reload (only spill registers that are live across calls)
  • Direct function calls (bypass function table lookup for known targets)
  • Depth guard caching (call depth check in register instead of memory)

The JIT uses W^X memory protection: code is written to RW pages, then switched to RX before execution. A signal handler converts memory faults in JIT code back to Wasm traps.

Module instantiation

WasmModule.load(bytes)       -> decode + validate + predecode
    |
    v
Instance.instantiate(store)  -> link imports, init memory/tables/globals
    |
    v
Vm.invoke(func_name, args)   -> execute via interpreter or JIT

The Store holds all runtime state: memories, tables, globals, function instances. Multiple module instances can share a store for cross-module linking.

Spec Coverage

zwasm targets full WebAssembly 3.0 compliance. All spec tests pass on macOS ARM64 and Linux x86_64.

Test results: 62,263 / 62,263 (100.0%)

Core specification

FeatureOpcodesStatus
MVP (core)172Complete
Sign extension7Complete
Non-trapping float-to-int8Complete
Bulk memory9Complete
Reference types5Complete
Multi-value-Complete
Total core201+100%

SIMD

FeatureOpcodesStatus
SIMD (v128)236Complete
Relaxed SIMD20Complete
Total SIMD256100%

Wasm 3.0 proposals

All 9 Wasm 3.0 proposals are fully implemented:

ProposalOpcodesSpec testsStatus
Memory64extends existingPassComplete
Tail calls2PassComplete
Extended constextends existingPassComplete
Branch hintingmetadata sectionPassComplete
Multi-memoryextends existingPassComplete
Relaxed SIMD2085/85Complete
Exception handling3PassComplete
Function references5104/106Complete
GC31PassComplete

Additional proposals

ProposalOpcodesStatus
Threads79 (0xFE prefix)Complete (310/310 spec)
Wide arithmetic4Complete (99/99 e2e)
Custom page sizes-Complete (18/18 e2e)

WASI Preview 1

46 / 46 syscalls implemented (100%):

CategoryCountFunctions
args2args_get, args_sizes_get
environ2environ_get, environ_sizes_get
clock2clock_time_get, clock_res_get
fd14read, write, close, seek, stat, prestat, readdir, …
path8open, create_directory, remove, rename, symlink, …
proc2exit, raise
random1random_get
poll1poll_oneoff
sock4NOSYS stubs

Component Model

FeatureStatus
WIT parserComplete
Binary decoderComplete
Canonical ABIComplete
WASI P2 adapterComplete
CLI supportComplete

121 Component Model tests pass.

WAT parser

The text format parser supports:

  • All value types including v128
  • Named locals, globals, functions, types
  • Inline exports and imports
  • S-expression and flat syntax
  • Data and element sections
  • All prefix opcodes: 0xFC (bulk memory, trunc_sat), 0xFD (SIMD + lane ops), 0xFE (atomics)
  • Wasm 3.0 opcodes: try_table, call_ref, br_on_null, throw_ref, etc.
  • GC prefix (0xFB): GC type annotations and struct/array encoding
  • 100% WAT roundtrip: 62,156/62,156 spec test modules parse and re-encode correctly

Total opcode count

CategoryCount
Core201+
SIMD256
GC31
Threads79
Others14+
Total581+

Security Model

zwasm enforces a clear boundary between guest (WebAssembly module) and host (embedding application or CLI).

Trust boundary

+-------------------+    WASI capabilities    +------------------+
|   Guest (Wasm)    | <-- deny-by-default --> |  Host (Zig/CLI)  |
|                   |                         |                  |
| Linear memory     |    Imports/exports      | Native memory    |
| Table entries     | <-- validated types --> | Filesystem, env  |
| Global variables  |                         | Network, OS APIs |
+-------------------+                         +------------------+

A valid Wasm module, no matter how adversarial, cannot:

  • Read or write host memory outside its own linear memory
  • Call host functions not explicitly imported
  • Bypass WASI capability restrictions
  • Execute code outside its validated instruction stream
  • Overflow the call stack or value stack without trapping

Defense layers

Module decoding

All binary input is bounds-checked. Resource limits prevent excessive allocation:

  • Section counts: 100-100,000 per section type
  • Per-function locals: 50,000 max (saturating arithmetic for overflow)
  • Block nesting depth: 500
  • LEB128 reads bounds-checked against binary slice

Validation

Full Wasm 3.0 type checking before any code executes. 62,263 spec tests verify correctness.

Linear memory isolation

  • Every load/store uses u33 arithmetic (address + offset) to prevent 32-bit overflow
  • Guard pages: 4 GiB + 64 KiB PROT_NONE region catches all out-of-bounds access
  • Signal handler converts memory faults to Wasm traps

JIT security

  • W^X: Code pages are RW during compilation, then switched to RX before execution. Never simultaneously writable and executable.
  • All branch targets validated against the register IR
  • Signal handler translates faults in JIT code to Wasm traps

WASI capabilities

Deny-by-default model with 8 capability flags:

FlagControls
allow-readFilesystem read
allow-writeFilesystem write
allow-envEnvironment variables
allow-pathPath operations (open, mkdir, unlink)
allow-clockClock access
allow-randomRandom number generation
allow-procProcess operations
allow-allAll of the above

32 of 46 WASI functions check capabilities before executing. The remaining 14 are safe operations (args size queries, fd_close, etc.).

Library API defaults (loadWasi()): cli_default — only stdio, clock, random, and proc_exit. Embedders needing full access use loadWasiWithOptions(.{ .caps = .all }).

--sandbox mode: Denies all capabilities, sets fuel to 1 billion instructions and memory ceiling to 256MB. Combine with --allow-* flags for selective access:

zwasm untrusted.wasm --sandbox --allow-read --dir ./data

--env KEY=VALUE: Injected environment variables are always accessible to the guest, even without --allow-env. The --allow-env flag controls access to host environment passthrough.

Stack protection

  • Call depth limit: 1024 (checked on every call)
  • Operand stack: fixed-size array, bounds-checked
  • Label stack: bounds-checked

What zwasm does NOT protect against

  • Timing side channels: No constant-time guarantees
  • Resource exhaustion: A module can loop forever (use --fuel to mitigate)
  • Host function bugs: If your host functions have vulnerabilities, Wasm code can trigger them
  • Spectre/Meltdown: No hardware-level mitigations
  • Information leakage via timing: JIT compilation time may vary with code structure

Recommendations

  • Build with ReleaseSafe for production (Zig’s bounds checks + overflow detection)
  • Use --fuel for untrusted modules to prevent infinite loops
  • Use --max-memory to cap memory usage
  • Grant only the WASI capabilities the module needs
  • See SECURITY.md for vulnerability reporting

Performance

Execution tiers

zwasm uses tiered execution:

  1. Interpreter: All functions start as register IR, executed by a dispatch loop. Fast startup, no compilation overhead.
  2. JIT (ARM64/x86_64): Hot functions are compiled to native code when call count or back-edge count exceeds a threshold.

When JIT kicks in

  • Call threshold: After ~8 calls to the same function
  • Back-edge counting: Hot loops trigger JIT faster (loop iterations count toward the threshold)
  • Adaptive: The threshold adjusts based on function characteristics

Once JIT-compiled, all subsequent calls to that function execute native machine code directly, bypassing the interpreter.

Binary size and memory

MetricValue
Binary size (ReleaseSafe)~1.2 MB
Runtime memory (fib benchmark)~3.5 MB RSS
wasmtime binary for comparison56.3 MB

zwasm is ~40x smaller than wasmtime.

Benchmark results

Representative benchmarks comparing zwasm against wasmtime 41.0.1, Bun 1.3.8, and Node v24.13.0 on Apple M4 Pro. 16 of 29 benchmarks match or beat wasmtime. 25/29 within 1.5x.

BenchmarkzwasmwasmtimeBunNode
nqueens(8)2 ms5 ms14 ms23 ms
nbody(1M)22 ms22 ms32 ms36 ms
gcd(12K,67K)2 ms5 ms14 ms23 ms
tak(24,16,8)5 ms9 ms17 ms29 ms
sieve(1M)5 ms7 ms17 ms29 ms
fib(35)46 ms51 ms36 ms52 ms
st_fib2900 ms674 ms353 ms389 ms

zwasm uses 3-4x less memory than wasmtime and 8-10x less than Bun/Node.

Full results (29 benchmarks): bench/runtime_comparison.yaml

SIMD performance

SIMD (v128) operations are JIT-compiled to native NEON (ARM64, 253/256 opcodes) and SSE (x86_64, 244/256 opcodes). v128 values are stored as split 64-bit halves in the register file.

Benchmarkzwasm scalarzwasm SIMDwasmtime SIMDSIMD speedup
image_blend (128x128)73 ms16 ms12 ms4.7x
matrix_mul (16x16)10 ms6 ms8 ms1.6x
byte_search (64KB)52 ms43 ms5 ms1.2x
dot_product (4096)142 ms190 ms15 ms0.75x

matrix_mul beats wasmtime. image_blend within 1.3x. dot_product is slower due to v128.load-heavy inner loop and split storage overhead.

Compiler-generated SIMD code (C -msimd128) shows larger gaps due to patterns like i16x8.replace_lane that are expensive with split v128 storage. Future work: contiguous v128 register storage (W37) to eliminate this overhead.

Full data: bench/simd_comparison.yaml

Benchmark methodology

All measurements use hyperfine with ReleaseSafe builds:

# Quick check (1 run, no warmup)
bash bench/run_bench.sh --quick

# Full measurement (3 runs, 1 warmup)
bash bench/run_bench.sh

# Record to history
bash bench/record.sh --id="X" --reason="description"

Benchmark layers

LayerCountDescription
WAT micro5Hand-written: fib, tak, sieve, nbody, nqueens
TinyGo11TinyGo compiler output: same algorithms + string ops
Shootout5Sightglass shootout suite (WASI)
Real-world6Rust, C, C++ compiled to Wasm (matrix, math, string, sort)
GC2GC proposal: struct allocation, tree traversal
SIMD10WAT microbench (4) + C -msimd128 real-world (5), scalar/SIMD

CI regression detection

PRs are automatically checked for performance regressions:

  • 6 representative benchmarks run on both base and PR branch
  • Fails if any benchmark regresses by more than 20%
  • Same runner ensures fair comparison

Performance tips

  • ReleaseSafe: Always use for production. Debug is 5-10x slower.
  • Hot functions: Functions called frequently will be JIT-compiled automatically.
  • Fuel limit: --fuel adds overhead per instruction. Only use for untrusted code.
  • Memory: Wasm modules with linear memory allocate guard pages. Initial RSS is ~3.5 MB regardless of module size.

Memory Model

Linear memory

Each Wasm module instance has up to one linear memory (or multiple with the multi-memory proposal). Linear memory is a contiguous byte array accessed by Wasm load/store instructions.

Allocation

  • Initial size specified in the module (e.g., 1 page = 64 KiB)
  • memory.grow extends the memory by the requested number of pages
  • Maximum size can be specified in the module or limited by --max-memory

Guard pages

zwasm allocates a 4 GiB + 64 KiB PROT_NONE region beyond the linear memory. Any out-of-bounds access lands in this guard region and is caught by the signal handler, which converts the fault to a Wasm trap. This avoids per-instruction bounds checks for most memory operations.

Addressing

All memory addresses use u33 arithmetic (32-bit address + 32-bit offset) to prevent overflow. This ensures that address + offset never wraps around to access valid memory.

GC heap

The GC proposal introduces managed heap objects (structs, arrays, i31ref). These live in a separate arena managed by zwasm:

  • Arena allocator: Objects are allocated from a pre-allocated arena
  • Adaptive threshold: GC collection triggers based on allocation pressure
  • Reference encoding: GC references on the operand stack use tagged u64 values

GC objects are not accessible from linear memory and vice versa. They exist in a separate address space.

Allocator parameterization

zwasm takes a std.mem.Allocator at load time. All internal allocations (module metadata, register IR, tables, etc.) go through this allocator. The linear memory itself uses mmap directly for guard page support.

This means you can:

  • Use a general-purpose allocator for normal usage
  • Use an arena allocator for batch processing (load, run, free everything)
  • Use a tracking allocator to monitor memory usage
  • Use a fixed-buffer allocator for embedded/constrained environments

Memory limits

ResourceDefault limitCLI flag
Linear memoryModule-defined max--max-memory <bytes>
Call stack depth1024Not configurable
Operand stackFixed sizeNot configurable
GC heapUnbounded (arena)Not configurable

Comparison

How zwasm compares to other WebAssembly runtimes.

Overview

Featurezwasmwasmtimewasm3wasmer
LanguageZigRustCRust/C
Binary size~1.2 MB56 MB~100 KB30+ MB
Memory (fib)3.5 MB12 MB~1 MB15+ MB
ExecutionInterp + JITAOT/JITInterpreterAOT/JIT
Wasm 3.0FullFullPartialPartial
GC proposalYesYesNoNo
SIMDFull (256 ops)FullPartialFull
WASIP1 (46 syscalls)P1 + P2P1 (partial)P1 + P2
PlatformsmacOS, Linux, WindowsmacOS, Linux, WindowsMany (no JIT)macOS, Linux, Windows

When to choose zwasm

Small footprint: When binary size and memory usage matter. zwasm is ~40x smaller than wasmtime.

Zig ecosystem: When embedding in a Zig application. zwasm integrates as a native zig build dependency with zero C dependencies.

Spec completeness: When you need full Wasm 3.0 support including GC, SIMD, threads, and exception handling in a small runtime.

Fast startup: The interpreter starts executing immediately. JIT compilation happens in the background for hot functions.

When to choose alternatives

Maximum throughput: wasmtime’s Cranelift AOT compiler produces highly optimized native code. For long-running compute-heavy workloads, wasmtime may be faster. SIMD microbenchmarks are competitive (matrix_mul beats wasmtime), but compiler-generated SIMD code shows larger gaps due to split v128 storage overhead.

Minimal size: wasm3 is ~100 KB and runs on microcontrollers. If you need the absolute smallest runtime without JIT, wasm3 may be a better fit.

WASI Preview 2: wasmtime has the most complete WASI P2 implementation. zwasm’s P2 support is via a P1 adapter layer.

Contributor Guide

Build and test

git clone https://github.com/clojurewasm/zwasm.git
cd zwasm

# Build
zig build

# Run unit tests
zig build test

# Run a specific test
zig build test -- "Module — rejects excessive locals"

# Run spec tests (requires wasm-tools)
python3 test/spec/run_spec.py --build --summary

# Run benchmarks
bash bench/run_bench.sh --quick

Requirements

  • Zig 0.15.2
  • Python 3 (for spec test runner)
  • wasm-tools (for spec test conversion)
  • hyperfine (for benchmarks)

Code structure

src/
  types.zig       Public API (WasmModule, WasmFn, etc.)
  module.zig      Binary decoder
  validate.zig    Type checker
  predecode.zig   Stack → register IR
  regalloc.zig    Register allocation
  vm.zig          Interpreter + execution engine
  jit.zig         ARM64 JIT backend
  x86.zig         x86_64 JIT backend
  opcode.zig      Opcode definitions
  wasi.zig        WASI Preview 1
  gc.zig          GC proposal
  wat.zig         WAT text format parser
  cli.zig         CLI frontend
  instance.zig    Module instantiation
test/
  spec/           WebAssembly spec tests
  e2e/            End-to-end tests (wasmtime misc_testsuite, 792 assertions)
  fuzz/           Fuzz testing infrastructure
  realworld/      Real-world compatibility tests (30 programs)
bench/
  run_bench.sh    Benchmark runner
  record.sh       Record results to history.yaml
  wasm/           Benchmark wasm modules

Development workflow

  1. Create a feature branch: git checkout -b feature/my-change
  2. Write a failing test first (TDD)
  3. Implement the minimum code to pass
  4. Run tests: zig build test
  5. If you changed the interpreter or opcodes, run spec tests
  6. Commit with a descriptive message
  7. Open a PR against main

Commit guidelines

  • One logical change per commit
  • Commit message: imperative mood, concise subject line
  • Include test changes in the same commit as the code they test

CI checks

PRs are automatically checked for:

  • Unit test pass (macOS + Ubuntu)
  • Spec test pass (62,263 tests)
  • E2E test pass (792 assertions)
  • Binary size <= 1.5 MB
  • No benchmark regression > 20%
  • ReleaseSafe build success