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.
Layout
Section titled “Layout”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 inniso_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 (theWebAssemblynamespace, its interfaces, ToJSValue and ToWebAssemblyValue, the import reading and the exports object), andbindings/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.
Memory.buffer
Section titled “Memory.buffer”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.
Deviations
Section titled “Deviations”- Exception handling: wasmi has no exception handling, so
TagandExceptionexist only on the JS side and modules that use tags fail to compile. Memory.prototype.toResizableBufferis 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
Tablevalue argument passed asundefinedis converted rather than treated as missing, as browsers andwasm/jsapi/table/get-set.any.jsdo. - Compilation runs on the DOM thread, not “in parallel”; only how long the caller waits differs.
- Porffor has no
WebAssemblyglobal (see docs/engines/porffor.md). - Every dedicated worker has its own store, so WebAssembly objects cannot
cross to one: posting or
structuredClone-ing aModulethrows DataCloneError, althoughModuleis[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).
Conformance
Section titled “Conformance”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.
Runtime choice
Section titled “Runtime choice”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.