Skip to content

WebAssembly

Generated from docs/platform/webassembly.md when the site was built at 7aa23e2.

niso implements the WebAssembly JavaScript Interface (webassembly.github.io/spec/js-api) and the streaming functions of the Web API (webassembly.github.io/spec/web-api) over wasmi, a WebAssembly interpreter written in Rust. iOS forbids JIT, so the runtime has to interpret, and the same runtime runs on every platform.

  • crates/niso-core/src/wasm.rs: the surrounding agent’s store, one per DOM. Modules have ids; functions, tables, memories and globals (“externs”) share one id space where each store address has exactly one id, which gives the JS API its object caches. Values cross as doubles (an i64 as its two 32-bit halves).
  • crates/niso-capi/src/wasm.rs: niso_wasm_*, documented in niso_runtime.h.
  • crates/niso-core/glue/wasm.js (niso-wasm’s glue section, held by niso-core until that crate exists): the JS API itself (the WebAssembly namespace, its interfaces, ToJSValue and ToWebAssemblyValue, the import reading and the exports object), and bindings/js/niso_js.c: its host functions.

Host functions made from JS functions call back into the glue synchronously through the wasm call hook; a JS exception unwinds through wasm as a wasmi host error and the export call that led there rethrows the same value. The asynchronous forms (compile, instantiate, the streaming ones) compile on the DOM thread and settle their promises from a task on the DOM’s task queue (Task::Wasm), never from another thread.

On QuickJS, Memory.buffer is an ArrayBuffer over the linear memory itself (JS_NewArrayBuffer with no free function), not a copy: writes from either side are visible to the other. When a memory grows, from Memory.grow or from wasm’s memory.grow (seen when the call returns or when wasm calls a host function), the old buffer is detached with JS_DetachArrayBuffer and the next read of buffer makes a new one, as the JS API’s refresh steps require. Script cannot detach a memory’s buffer: transfer and transferToFixedLength throw on it. Tests: crates/niso-quickjs/tests/wasm.rs.

  • Exception handling: wasmi has no exception handling, so Tag and Exception exist only on the JS side and modules that use tags fail to compile.
  • Memory.prototype.toResizableBuffer is missing. A resizable buffer must keep its identity while the memory’s bytes move on growth, which no QuickJS API allows for an external buffer.
  • Shared memories and threads: wasmi has no threads proposal.
  • Builtin sets (js-string) are never found, so modules that import them see ordinary imports.
  • A Table value argument passed as undefined is converted rather than treated as missing, as browsers and wasm/jsapi/table/get-set.any.js do.
  • Compilation runs on the DOM thread, not “in parallel”; only how long the caller waits differs.
  • Porffor has no WebAssembly global (see docs/engines/porffor.md).
  • Every dedicated worker has its own store, so WebAssembly objects cannot cross to one: posting or structuredClone-ing a Module throws DataCloneError, although Module is [Serializable], because its compiled code lives in the store that compiled it. A worker compiles its own from the bytes. Memory, Instance, Table, Global, Tag and Exception are not serializable anyway (shared memories would be, but there are none).

WPT wasm/jsapi: 1017 of 1158 subtests pass. Most of what fails is tentative proposals (ESM integration, type reflection, JSPI, GC, exceptions, js-string), shared memories, toResizableBuffer, and two tests that need iframes.

Measured on a 4-core AMD EPYC cloud machine (x86_64), on 2026-11-08.

Tokenizing lines 50000-60000 of TypeScript 5.9.3’s typescript.js (10,001 lines, 482 KB) with vscode-textmate 9.3.2 and vscode-oniguruma 2.0.1, the JavaScript grammar from VS Code 1.145.2 (tests/wasm-bench/run.sh; three runs each):

Total In findNextMatchSync (wasm and the JS around it) loadWASM
niso (QuickJS + wasmi) 16.4-18.2 s 11.9-13.2 s 12-13 ms
node 24 (V8, JIT) 1.45-1.58 s 1.07-1.14 s 33-43 ms

That is about 1.7 ms a line, or about 85 ms for a 50-line viewport. Inside the scanner niso is about 11x slower than V8, and the JS around it (QuickJS against V8) about 7x. A call from JS into a trivial export costs 1.1-1.2 µs on niso (17-32 ns on V8), most of it in the QuickJS glue, which is the first place to optimize.

wasmi against wasm3 (28ecb9a, 2026-09-29) on the same Rust program built for wasm32-wasip1 (regex over the sample, then a byte hash loop), each run twice:

regex hash loop Size
native 12 ms 25 ms
wasmi 2.0.0 (lazy translation) 99-109 ms 70-83 ms +1.9 MB in a stripped LTO binary (simd, memory64)
wasm3 121-122 ms 91-95 ms 0.55 MB static library

wasmi is the right default:

  • Speed: it is as fast as or faster than wasm3, the fastest interpreter before it, on this workload.
  • Spec support: wasmi supports Wasm 2.0 plus SIMD, relaxed SIMD, memory64, multi-memory, tail calls and extended-const. wasm3 has no fixed-width SIMD, which current toolchains emit, but it does have exception handling, which wasmi still lacks. wasm3’s author announced minimal maintenance.
  • Integration: it is Rust and a normal dependency of the core, with fuel and resource limits and no C toolchain. It costs about 1.9 MB against wasm3’s 0.5 MB, which matters most on watchOS.

A JIT (wasmtime, V8) would be about 10x faster, but iOS does not allow one. On other platforms a JIT could sit behind the same niso_wasm_* interface later.