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,158 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.4 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.

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 without JIT?

Yes. The interpreter handles all functions by default. JIT is only triggered for hot functions. There is no way to disable JIT for specific functions, but functions that are called fewer than ~8 times will always use the interpreter.

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~2K
validate.zigType checker, operand stack simulation~1.7K
predecode.zigStack IR → register IR conversion~0.7K
regalloc.zigVirtual → physical register allocation~2K
vm.zigInterpreter, execution engine, store~8K
jit.zigARM64 JIT backend~5.9K
x86.zigx86_64 JIT backend~4.7K
types.zigCore type definitions, value types~1.3K
opcode.zigOpcode definitions (581+ total)~1.3K
wasi.zigWASI Preview 1 (46 syscalls)~2.6K
gc.zigGC proposal: heap, struct/array types~1.4K
wat.zigWAT text format parser~5.9K
cli.zigCLI frontend~2.1K
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
  • x86_64: Full support — same coverage as ARM64

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,158 / 62,158 (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,158 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.4 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 operations are functionally complete (256 opcodes, 100% spec) but run on the stack interpreter, not the register IR or JIT. This results in ~22x slower SIMD execution vs wasmtime. Planned improvement: extend register IR for v128, then selective JIT NEON/SSE.

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

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.4 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, LinuxmacOS, 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. In particular, SIMD-heavy workloads are currently ~22x slower on zwasm (stack interpreter, no SIMD JIT yet).

Windows support: zwasm currently supports macOS and Linux. For Windows, use wasmtime or wasmer.

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,158 tests)
  • E2E test pass (792 assertions)
  • Binary size <= 1.5 MB
  • No benchmark regression > 20%
  • ReleaseSafe build success