Skip to content

Node-API (V8 under Node and Deno, JavaScriptCore under Bun)

Generated from docs/engines/napi.md when the site was built at 7aa23e2.

crates/niso-napi is the JS binding as a Node-API addon. It uses the same niso_js.c, glue and generated host functions as QuickJS, with the engine interface (niso_engine.h) written in Rust over Node-API. A page’s DOM runs in the runtime’s process, its scripts run in a realm of their own, and the runtime’s event loop drives it. The runtime is Node, Deno or Bun (see “Runtimes” below); the text says Node where the three behave alike. This gives niso a JIT engine without shipping one, and it runs the same pages under QuickJS, V8 and JavaScriptCore so they can be diffed.

Tested on macOS arm64 with Node 26.7.0, Node 20.20.2, Deno 2.9.8 and Bun 1.4.4, and on Linux x86_64 with Node 26.10.0, Node 20.20.2, Deno 2.9.8 and Bun 1.4.4.

Terminal window
cargo build --release -p niso-napi # target/release/libniso_napi.{dylib,so}
node --experimental-vm-modules bindings/js/napi/niso-node.js --dump --tap 60,140 examples/dist/counter.js
cd bindings/js/napi && node --experimental-vm-modules test.js # or: cargo test -p niso-napi
cd bindings/js/napi && deno run -A test.js # or: bun test.js
cargo run --release -p niso-wpt -- --engine node # target/wpt-napi/diff.md
NISO_RUNTIME=deno cargo run --release -p niso-wpt -- --engine node # or a bun or another node binary

cargo test -p niso-napi runs test.js under each of node, deno and bun found on PATH, one at a time. It fails without node unless NISO_SKIP_NODE=1, and skips a missing deno or bun.

bindings/js/napi/niso.js finds the addon through NISO_NAPI, or else the newest build under target/. niso-node takes niso-run’s options (--html, --page, --root, --url, --dump, --boxes, --max-ms, --tap, --repeat, --render, --time). It also takes --live, which runs the page in real time from Node’s loop until the page has nothing left to do.

The engine interface is C: niso_js.c calls qe_* functions with raw handles and expects the engine to hold values across calls. napi-rs offers two things: safe, lifetime-checked wrappers, and #[napi] macros that generate a JS module from Rust types. The binding would bypass both. The engine keeps raw napi_values and napi_refs in its own tables, opens and closes callback scopes for microtask checkpoints, and runs the page from a libuv timer, and napi-rs wraps none of that. napi-sys is the layer napi-rs is built on (the raw functions, loaded at run time on Windows). napi-build sets the link flags addons need (-undefined dynamic_lookup on macOS). libuv-sys-lite declares the libuv timer functions. The addon links neither Node-API nor libuv: both resolve in the process that loads it.

Each page is a vm context that niso.js makes. The glue runs in it and makes its global object the page’s Window. A page therefore sees niso’s fetch, URL, EventTarget, timers, structuredClone and the rest, and none of Node’s: process, require, Buffer, setImmediate and Node’s own fetch are absent (the test “the page’s realm has none of Node’s globals” in test.js checks this). The page shares with Node only what V8 puts in every context: the ECMAScript builtins, WebAssembly, and console, which the glue replaces with niso’s. Node-API makes objects in Node’s realm, so the engine makes the arrays and ArrayBuffers it hands the page with the page realm’s own constructors.

The realm is there for correctness, not isolation: a page’s globals, errors and promises look like its own, as in a browser. It is no security boundary. Node’s documentation says node:vm is not a security mechanism, and the runtime’s own code runs on the page’s stack (see the stack’s limit below), so under Node-API a page shares its host’s trust. Content that is not trusted needs a process of its own.

Node 22.8 and later make the context with vm.constants.DONT_CONTEXTIFY, which gives an ordinary global object. Older Node contextifies an object it is given: its interceptors look a name up on that object first, and call the global’s own accessors with that object as this. niso.js gives Node 20 an object with a null prototype. Names the global inherits (constructor, hasOwnProperty) then still come from the page’s Window.prototype chain rather than from Node’s Object.prototype. One difference remains: a handler set by plain assignment at top level (onmessage = f) becomes an own data property of the global. niso still finds the handler and calls it.

Module scripts run as vm.SourceTextModule, so Node needs --experimental-vm-modules, which the shebang of niso-node.js passes.

A page’s import() must give a promise of the page’s realm, settled with the page’s values: a promise or error of Node’s realm leads, through its constructor.constructor, to Node’s Function and so to process. Two kinds of Node fail this, and niso.js checks for both when it loads and then makes no page (new Page() throws and says why):

  • Node before 20.19.5, 22.17.0 and 24.2.0 returns the promise of Node’s realm for an import() in a vm context (nodejs/node#58309 fixed it). On Node 20.8.1, import('x').constructor.constructor('return process')() returned process in a page.
  • Newer Node without --experimental-vm-modules rejects a page’s import() with an error of its own realm before it asks niso.js.

On a Node that passes both checks, every error that reaches a page is the page’s: niso.js remakes an error of Node’s realm (from vm or the addon) as the page’s error with the same name, message and stack. test.js checks this over each error path: failed imports, a module with a syntax error, a loader that throws, concurrent imports of one module, two graphs sharing a module that awaits, stack overflow around host calls, fetch failures, and a worker’s error events.

The exception is import() near the stack’s limit. Node runs frames of its own before it calls niso’s callback, and when one of them overflows, the page’s import() rejects with Node’s RangeError, which leads to process. niso.js closes what is its own: it makes the callback in the page’s realm, so an overflow as it starts throws the page’s RangeError, and it does the import from a microtask, on a fresh stack. test.js imports at every depth near the limit and checks that no error of niso’s frames gets through; on macOS the runtime’s own still reject 20 or so of 800 imports under Node 26, 8 under Node 20, 10 under Deno and 4 under Bun.

Node owns the loop. A live page runs from a libuv timer. On each tick it runs the tasks that are due. It then sleeps until the next display frame while it animates (niso_dom_needs_frame), or until its next timer otherwise (niso_dom_next_timer). The DOM’s wake hook is a thread-safe function, so a message from a module or a network thread wakes the page on the loop’s thread. A wake is queued at most once until it runs, so a burst of them (messages, the engine’s WebAssembly settles) is one call on the loop’s thread.

Tests use Page.run(maxMs), which is niso’s virtual time: the clock skips ahead to each timer, and the run waits for the network in real time. Each step runs the DOM up to the run’s deadline or the current clock, whichever is earlier (niso_js_run_until). As with QuickJS’s single niso_js_run, anything tasks post after the deadline waits for the next run.

V8 can finish some of its own work outside the DOM’s tasks: a WebAssembly compile settles from a task V8 posts to Node’s loop. So a run steps once per turn of the loop, as setImmediate does, and skips the clock only after two turns in which the page called no host function. The engine’s WebAssembly promises can take longer than that: Deno runs one batch of V8’s tasks per turn, and JSC (Bun) and Deno’s V8 can finish a compile on another thread (Bun took up to 12 turns). So the glue counts the compile and instantiate promises it has not seen settle (host.engineWork), and while any is pending a run steps in real time and neither settles nor moves its clock. Live mode does not wait for any of this. The core’s run returns once it has run for a frame’s time past its until, with work still ready (a chain of IndexedDB requests); a run past its deadline then settles and leaves that work to the next run, as one niso_js_run does under QuickJS, and a live page runs again at once.

Microtasks are the engine’s: the page’s context shares the isolate’s queue with the host’s. Under Node a checkpoint opens and closes a Node-API callback scope. Node then drains the queue, but only when that scope is the outermost one, so never while script is on the stack. Deno and Bun drain nothing when a callback scope closes, so niso.js hands the addon their own drain (Deno’s internal core.runMicrotasks, bun:jsc’s drainMicrotasks), and a checkpoint calls it only while the loop runs a page, with none of the host’s JS on the stack. Without it a task’s microtasks ran after the next task, and a live worker that started work from a reaction stalled. A Deno or Bun that has no such drain gets no page.

vm’s microtaskMode: 'afterEvaluate' would give the page its own queue, but it is not used: it drains that queue at the end of every nested runInContext, which can be in the middle of a script.

Unhandled rejections arrive at process.on('unhandledRejection'). niso.js reports each one to the page whose Promise.prototype made the promise, after the checkpoint where it was left unhandled. Node emits the event at the end of every callback, but Deno only where its loop drains process.nextTick’s queue, so Page.run settles a tick and a turn after the run ends, once the host has reported what the run left.

Wrapper identity lives in Node-API references, through host.adopt and host.wrapper, instead of the glue’s WeakRef and FinalizationRegistry. A wrapper’s reference is weak, and strong while its node is connected. Its finalizer releases the node’s handle, as the registry would. niso-quickjs’s lifetime tests pass on this binding (test.js ports them).

Page.gc() does three things. It runs a full V8 GC with a gc function taken from a context made while --expose-gc was briefly set, so pages never see it (under Bun it uses Bun.gc). It then waits a turn of the loop for the finalizers. Last, it runs the DOM’s collection.

V8 collects lazily, so a wrapper JS dropped lives until the next GC; QuickJS frees one as soon as nothing refers to it. An object the DOM makes once for a node or the window (a style sheet and its rules, classList, style, location) lives as long as its owner’s wrapper (host.owner), and a [SameObject] attribute’s value is kept on the wrapper that returned it, so both engines keep these objects and their expandos through a GC. A cycle back to the owner (p.classList.owner = p) does not keep a removed node alive on either engine. Probing every [SameObject] attribute niso implements, with an expando and a full GC, found two kinds that lose identity on both engines. A template’s content did because niso’s IDL did not mark it [SameObject]; it now does. The collections (children, childNodes, forms, links, a form’s elements, a select’s options, a table’s rows and tBodies, a row’s cells) still do: each read makes a new snapshot of the elements, which is how they look live. Keeping one would freeze it, so they need live collections in the core first.

A wrapper of a node that is not connected is still weak: a detached subtree that only the DOM holds (a removed node’s child, with only the removed node in JS) gets a new wrapper, without its expandos, once its old one is collected: at once on QuickJS, at the next GC on V8. A browser keeps it.

The page gets V8’s WebAssembly, not wasmi. Node-API can only make an ArrayBuffer over foreign bytes in Node’s realm, so qe_can_alias is false and the glue leaves wasmi out. The glue defines compileStreaming and instantiateStreaming over niso’s Response, because V8’s versions expect Node’s. niso.js sets --no-wasm-async-compilation so that a compile settles promptly within a virtual-time run (see below), and a run waits for the engine’s pending WebAssembly promises (see “Event loop and microtasks”). Under Bun the page gets JSC’s WebAssembly the same way.

V8 flags are the process’s, so a flag niso.js sets also applies to the host’s own code. It sets as few as it can:

  • --expose-gc, only while it makes one context to take gc from, then --no-expose-gc. Contexts made later, the pages’ included, have no gc. niso’s worker threads leave the flag alone: they start together, and one that turned it off while another made its context ended that thread before its worker’s page began.
  • --no-wasm-async-compilation, for good. A page’s WebAssembly.compile then compiles on the thread that asks, as wasmi does, and V8 settles its promise from a task it posts to the loop at once. Without it the compile runs on V8’s threads, and a run can skip its virtual clock past a compile that has not finished. The host’s own WebAssembly compiles the same way, so a large module blocks Node’s thread while it compiles; results do not change.
  • --harmony-rab-gsab-transfer, on Node 20 only, whose V8 has ArrayBuffer.prototype.transfer and detached only behind it (see below).

Deno takes them as Node does. Under Bun, niso.js sets none of them.

A worker runs on a thread of the runtime’s, in a realm of its own as the window’s is. The engine leaves qe_runs_workers false, so the core does not start a std::thread (as it does under QuickJS and Porffor); it asks its worker host, and the addon’s host calls the realm’s startWorker. In niso.js that starts a worker_threads Worker (Node, Deno and Bun all have node:worker_threads) whose script requires niso.js again. There niso.js makes the worker’s page with the addon’s startWorker, which takes the worker’s DOM from the core (niso_worker_begin) on that thread, and runs it live from the thread’s own loop. Each loop keeps its own inbox: a message reaches a worker’s DOM only on its thread, through the wake hook. As under QuickJS, a worker’s IndexedDB is in memory and its own (DECISIONS.md).

terminate() closes the worker’s inbox at once, so nothing it posts reaches the window and a run does not wait for it, and calls the thread’s terminate(). Node and Bun stop the thread even in a busy loop, and its page ends in the environment’s cleanup. Deno 2.9.8’s terminate() only wakes the worker’s event loop: it does not interrupt a running script, so a worker in a busy loop keeps its thread spinning until the process exits. The window no longer sees it, but its CPU is lost, and freeing the window waits up to 2 s for the thread before it gives up on it.

A page that ends (freed, or a worker’s page that closed) first terminates the workers it started through the core’s list (niso_dom_terminate_workers), while the realm’s terminateWorker can still run; its DOM then waits for them. A worker the runtime cannot start (startWorker throws) gets an error event, as one whose script cannot be fetched does.

A worker’s page runs live, and between turns tells the core whether it waits with nothing to do; the window’s run counts a worker that does as idle and lets its clock skip ahead. A worker with one of the engine’s WebAssembly promises pending (see “Event loop and microtasks”) is not idle, and neither is one whose last run left work ready or that has a WebSocket open (set_waiting checks what a worker the core runs checks before it waits).

test.js checks the realm’s globals, nested workers, sixteen workers started together, terminate() during a busy loop, a closing worker’s own workers, a worker the runtime cannot start, posting to a live window that waits with nothing to do, a worker’s pending WebAssembly, a worker whose run left work ready or has a WebSocket open, the place of a worker’s parse error, that a worker’s IndexedDB is not the window’s, and that lines workers print at once stay whole.

niso.js uses only node:vm, node:worker_threads, node:v8 and process, which Deno and Bun also provide, and the addon only Node-API and the libuv calls they emulate. It checks when it loads that the runtime gives a page’s import() the page’s promises and errors, and if not, new Page() throws and says why. A realm is not a security boundary (see “The realm”).

runtime engine pages binding tests, macOS arm64 binding tests, Linux x86_64
Node before 20.19.5, 22.17.0, 24.2.0 (tested: 20.8.1) V8 refused: a page’s import() returns a promise of Node’s realm (nodejs/node#58309)
Node 20.19.5+, 22.17.0+, 24.2.0+ with --experimental-vm-modules V8 yes 20.20.2, 26.7.0: 55/55 20.20.2, 26.10.0: 55/55
Node 26 without --experimental-vm-modules V8 refused: a page’s import() rejects with an error of Node’s realm
Deno 2.9.8 V8 yes 55/55 55/55
Deno 2.9.5 V8 yes 23 pass, then 2 fail and it hangs
Bun 1.4.4 JSC yes 55/55 55/55
Bun 1.1.29 JSC refused: the vm global’s prototype cannot change, so it cannot become a Window

What each needed or still lacks:

  • Deno loads a worker’s script as an ES module, so a worker’s thread runs require(niso.js) from an eval, and package.json marks the directory CommonJS. Deno drops the loop’s handles before an environment’s cleanup hooks run, has no handle scope there, and runs deleted references’ finalizers, so the addon leaves a page’s handles, finalizers and async context to the environment then. It emits unhandledRejection late and runs V8’s tasks one batch per turn, and its callback scopes drain no microtasks (see “Event loop and microtasks”); its worker terminate() cannot stop a busy script (see “Workers”). Deno 2.9.5 passes the tests before the workers’ and fails the first two worker tests, whose messages wait on a libuv timer, then hangs in the next: a native uv_timer there does not wake the loop on time, likely denoland/deno#36454 (fixed by #36559), not verified. Use 2.9.8 or later.
  • Bun before 1.4 has a vm global whose prototype cannot change. Bun 1.4 makes a page’s realm with dontContextify, and JSC rejects a page’s failed import() with the page’s own error before asking niso.js. import() returns the module’s namespace, without which Bun lost a re-export from a module with top-level await. Its callback scopes drain no microtasks either. Page.gc() uses Bun.gc.
  • Node 20.19.5+ has V8 11.3: see the next section.

Node 20’s V8 has ArrayBuffer.prototype.transfer and detached only behind --harmony-rab-gsab-transfer. The glue’s postMessage and structuredClone transfer ArrayBuffers with them, so niso.js sets the flag before it makes a page’s context.

V8 11.3 also lacks some newer parts of the WebAssembly JS API: JS string builtins, WebAssembly.JSTag, Memory.prototype.toFixedLengthBuffer, and the Exception constructor’s length and stack.

niso-wpt --engine node runs the tests niso-wpt would (by default the Baseline suite, docs/wpt.md) under niso-node, once each, with the runtime NISO_RUNTIME names (node by default). It compares the results with this system’s expectations, which are QuickJS’s, file by file and writes only under target/wpt-napi/: diff.md, results.json, and on a full run the Baseline score, baseline.json. Differences do not fail the run, and --update, flaky.txt and the tracked score are QuickJS’s alone.

The Baseline suite (19,185 files) on Linux x86_64 (20 cores, load average 40 to 85), engine-napi after merging main (68dd027), on 2026-11-09:

subtests passing widely available features passing all / ≥ 90% newly available time
QuickJS (niso-wpt, the gate) 186216 / 1165185 51 / 91 4 / 10 344 s
Node 26.10.0 (--engine node, one run) 284734 / 1165297 56 / 94 8 / 14 547 s

Of the differences, 98,815 subtests pass on Node only outside the files tests/wpt/flaky.txt lists, almost all in wasm/core (V8’s SIMD, GC and exception proposals, which wasmi lacks). 1,000 pass on QuickJS only: 832 in files flaky.txt lists, CSS transitions and animations that sample the real clock, and most of the other 168 in more such files, which a single run under this load does not settle; the rest are test names that print values (below).

Before the Baseline suite, Linux x86_64 (20 cores), engine-napi after merging main (eeb3cd4), on 2026-11-09; the 3056 files of tests/wpt/suites.txt:

runtime subtests passing pass on QuickJS only pass on Node-API only time
QuickJS (niso-wpt) 89233 / 114710 63 s
Node 26.10.0 89462 / 114808 31 261 60 s
Node 20.20.2 89215 / 114708 82 65 69 s
Bun 1.4.4 89475 / 114792 25 268 52 s
Deno 2.9.8 89464 / 114808 29 261 75 s

The totals differ because a file that stops early counts only the subtests it reached. By cause:

cause QuickJS only Node-API only
Test names print values, and QuickJS and V8 or JSC format them differently: Date.prototype.toString (V8 and JSC add the zone’s name) in IndexedDB/structured-clone.any.js and its worker variant, and Function.prototype.toString in DOMImplementation-createDocument.html. These pass on every engine. 23 (Node 20: 25) 23
V8 and JSC join a self-referential array to '' instead of exhausting the stack (IndexedDB/keypath.any.js, both variants). 2
wasmi lacks what V8 and JSC have: the GC, typed-function-reference and exception proposals (*.tentative.any.js), shared memories and Memory.prototype.grow on them. 208 (Bun 216, Node 20 12)
V8 behaves differently from the WebAssembly JS API’s IDL: Global.prototype.value’s setter accepts no argument (Node 26), and WebAssembly.Module’s interface object (Node 26, Deno). Node 20’s are in the next row. 6 (Deno 4)
V8 11.3 (Node 20) lacks newer WebAssembly JS API (JS string builtins, JSTag, toFixedLengthBuffer, the Exception constructor’s length and stack), reads instantiate’s options for a Buffer argument, and lacks Float16Array (idb-binary-key-roundtrip, getRandomValues, Blob-constructor, interface-objects/001.worker.html). 55
Not investigated, SharedArrayBuffer tests: WebCryptoAPI/sharedarraybuffer.https, no-coop-coep.https and serialization-via-idb. 17
Not investigated, workers: Worker-terminate-forever-during-evaluation.html and dedicated-worker-import-{blob,data}-url. 10
HTMLLinkElement-disabled-005/007.html time out on QuickJS, and hr-time/timeOrigin.html depends on timing there (a known issue on main; not under Bun). 3 (Bun 2)

Node 26, Deno and Bun close every gap of V8 11.3 above: the JS string builtins (wasm/jsapi/js-string/constants passes 40/40), toFixedLengthBuffer and Float16Array included.

Worker files used to time out at random under Node-API, on loaded machines more. Two causes were bugs, now fixed: worker threads raced on V8’s --expose-gc flag (see “Process-wide V8 flags”), and a worker whose run left work ready, or whose engine had WebAssembly pending, counted as idle (see “Workers”).

The first run also found four bugs in the binding and the glue, all now fixed:

  • Node’s Object.prototype reached the page through the contextified global.
  • A virtual-time run went past its deadline, so pages whose messages post each other never settled.
  • ArrayBuffers could not be transferred on V8 11.3.
  • structuredClone dropped an error’s stack. On V8 and JSC, stack is an own property of each error; on QuickJS it is an accessor.

The run after merging main found three more, also fixed:

  • Every wrapper started out weak, because the glue passed the “connected” flag to adopt as a number, which napi_get_value_bool rejects.
  • Wrappers of objects a node or the window owns (style, classList, document.styleSheets, location and others) were not kept alive, so QuickJS freed them at once and lost their identity and expandos. Each now lives as long as its owner’s wrapper.
  • Array keys and decoded strings were built by assignment, so an indexed setter on Object.prototype could drop an entry or a character (IndexedDB/bindings-inject-keys-bypass.any.js).

Measured on Linux x86_64 (20 cores, shared, load around 20) with release builds after merging main, on 2026-11-09. The numbers are noisy.

tests/wasm-bench/run.sh tokenizes 10k lines of TypeScript with vscode-textmate over vscode-oniguruma (VS Code’s tokenizer, on a wasm build of Oniguruma). Medians of 3 runs; “plain” runs the same code in the runtime with no niso:

tokenize of which wasm scans one JS-to-wasm call
niso-run (QuickJS + wasmi) 8150 ms 5757 ms 551 ns
niso-node, Node 26 871 ms 641 ms 19 ns
niso-node, Node 20 983 ms 709 ms 12 ns
niso-node, Deno 914 ms 674 ms 14 ns
niso-node, Bun 918 ms 672 ms 6 ns
plain Node 26 805 ms 605 ms 12 ns
plain Node 20 885 ms 647 ms 14 ns
plain Deno 855 ms 644 ms 14 ns
plain Bun 836 ms 620 ms 7 ns

niso under each runtime tokenizes within 10% of the runtime alone, about nine times as fast as QuickJS with wasmi.

Cold start of examples/dist/counter.js with --time, medians of 21 runs. “Wall” is the whole process. “Startup” runs from the runner’s main function to the first rendered frame; for niso-node it leaves out the runtime’s own boot and loading the addon. A tap’s frame is the mean of 50. “60 s of timers” is the wall time of a process that runs setInterval(f, 4) for 60 s of virtual time (15000 callbacks).

wall startup engine init scripts first frame max RSS tap frame 60 s of timers
niso-run (QuickJS) 45 ms 42 ms 36 ms 4.3 ms 1.6 ms 29 MB 1.6 ms 51 ms
niso-node, Node 26 66 ms 35 ms 28 ms 5.3 ms 1.7 ms 81 MB 1.7 ms 99 ms
niso-node, Node 20 62 ms 34 ms 27 ms 5.1 ms 1.9 ms 71 MB 1.7 ms 94 ms
niso-node, Deno 79 ms 40 ms 30 ms 8.4 ms 1.9 ms 78 MB 2.1 ms 100 ms
niso-node, Bun 47 ms 35 ms 25 ms 8.3 ms 2.1 ms 67 MB 2.0 ms 88 ms

The runtimes add 40 to 50 MB. A virtual-time run of timers costs about as much under every runtime as under QuickJS: most of each row’s last column is the process’s start.

On macOS arm64 (12 cores, heavily loaded), before the merge: niso-run starts counter.js in 79.5 ms (engine 61.6, scripts 5.2, first frame 12.7) with a 0.24 ms tap frame, and niso-node on Node 26 in 58.9 ms (42.2, 4.6, 12.0) with 0.27 ms.

Acid3 scores 61/100 on both engines, with the same failing tests, and the boxes of Acid1 and Acid2 (--boxes) are identical. tests/svelte/dist/main.js mounts and runs the same under niso-node as under niso-run.