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.
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.jscd bindings/js/napi && node --experimental-vm-modules test.js # or: cargo test -p niso-napicd bindings/js/napi && deno run -A test.js # or: bun test.jscargo run --release -p niso-wpt -- --engine node # target/wpt-napi/diff.mdNISO_RUNTIME=deno cargo run --release -p niso-wpt -- --engine node # or a bun or another node binarycargo 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.
Why napi-sys rather than napi-rs
Section titled “Why napi-sys rather than napi-rs”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.
The realm
Section titled “The realm”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 avmcontext (nodejs/node#58309 fixed it). On Node 20.8.1,import('x').constructor.constructor('return process')()returnedprocessin a page. - Newer Node without
--experimental-vm-modulesrejects a page’simport()with an error of its own realm before it asksniso.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.
Event loop and microtasks
Section titled “Event loop and microtasks”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.
Lifetime and GC
Section titled “Lifetime and GC”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.
WebAssembly
Section titled “WebAssembly”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.
Process-wide V8 flags
Section titled “Process-wide V8 flags”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 takegcfrom, then--no-expose-gc. Contexts made later, the pages’ included, have nogc. 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’sWebAssembly.compilethen 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 hasArrayBuffer.prototype.transferanddetachedonly behind it (see below).
Deno takes them as Node does. Under Bun, niso.js sets none of them.
Workers
Section titled “Workers”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.
Runtimes
Section titled “Runtimes”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, andpackage.jsonmarks 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 emitsunhandledRejectionlate and runs V8’s tasks one batch per turn, and its callback scopes drain no microtasks (see “Event loop and microtasks”); its workerterminate()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 nativeuv_timerthere 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
vmglobal whose prototype cannot change. Bun 1.4 makes a page’s realm withdontContextify, and JSC rejects a page’s failedimport()with the page’s own error before askingniso.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()usesBun.gc. - Node 20.19.5+ has V8 11.3: see the next section.
V8 11.3 and Node 20
Section titled “V8 11.3 and Node 20”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.
WPT against QuickJS
Section titled “WPT against QuickJS”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.prototypereached 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.
structuredClonedropped an error’sstack. On V8 and JSC,stackis 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
adoptas a number, whichnapi_get_value_boolrejects. - Wrappers of objects a node or the window owns (
style,classList,document.styleSheets,locationand 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.prototypecould drop an entry or a character (IndexedDB/bindings-inject-keys-bypass.any.js).
Performance
Section titled “Performance”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.