Performance
The RISC-V runner interprets every guest instruction, so code on it runs much slower than native; the host-engine and wasm runners run at JIT and native-wasm speed. Here are measured numbers and how to stay fast.
Measure it in your browser
Instructions per second, here
Runs a shell loop on the emulator and divides the guest instructions retired (raw.instructionCount()) by wall-clock time. The counter overstates block-cached instructions by up to about 25% (see below), so treat it as an upper bound. The expected output was recorded in Chromium on an Apple M3 Max.
const raw = nano.raw;
const t0 = performance.now();
const i0 = raw.instructionCount();
const r = await nano.shExec("i=0; while [ $i -lt 20000 ]; do i=$((i+1)); done; echo $i", { maxSteps: 1e10 });
const ms = performance.now() - t0;
const insns = raw.instructionCount() - i0;
print(r.stdout.trim(), "iterations:", Math.round(insns / 1e6), "M guest instructions in", Math.round(ms), "ms ≈", Math.round(insns / ms / 1000), "MIPS");20000 iterations: 527 M guest instructions in 1748 ms ≈ 301 MIPS
The same loop on three runners
Ten million additions in a JS loop on the host engine and on the Boa interpreter, and 20,000 iterations of a shell loop on the RISC-V runner. Your timings will differ; the expected output was recorded in Chromium on an Apple M3 Max.
let t = performance.now();
await nano.node(["-e", "let s = 0; for (let i = 0; i < 1e7; i++) s += i; console.log(s)"]);
print("host-engine Node, 1e7 iterations:", Math.round(performance.now() - t), "ms");
t = performance.now();
await nano.script("let s = 0; for (let i = 0; i < 1e6; i++) s += i; s", { limits: { loopIterations: 2e6 } });
print("Boa, 1e6 iterations:", Math.round(performance.now() - t), "ms");
t = performance.now();
await nano.shExec("i=0; while [ $i -lt 20000 ]; do i=$((i+1)); done", { maxSteps: 1e10 });
print("BusyBox sh on RISC-V, 2e4 iterations:", Math.round(performance.now() - t), "ms");host-engine Node, 1e7 iterations: 85 ms Boa, 1e6 iterations: 286 ms BusyBox sh on RISC-V, 2e4 iterations: 1803 ms
Node.js on the emulator
Booting Node.js v25.4.0 and running console.log(1) retires about 505 million guest instructions. With the command-line runner (node test/run.mjs runners/riscv/images/node --cmd node -e …) on an Apple M3 Max under Node.js 24.11.1 that took 1.96 s and 2.04 s wall-clock in two runs, including the host's own start-up. In a browser tab the first run also downloads the 62 MB ELF from the catalog (cached in OPFS afterwards).
Slowdown by workload
From the repository's benchmark suite (bench/, recorded in bench/PERFORMANCE.md): each benchmark run with native Node.js and with the RISC-V Node.js ELF on NanoVM, Apple Silicon. These are the baseline numbers, before the block-engine rework below.
The block-engine rework (block dispatch at the top of exec(), superblocks across forward branches, FP/atomics inside blocks, a 16,384-entry block cache) raised block coverage on loop-compute from 0.2% to 100% and cut its wall-clock time by 13% (67.3 s → 58.2 s); math-compute went from 17.8 s to 15.4 s. The counters behind "MIPS" over-count block instructions by about 25%, so wall-clock time is the number to trust.
Staying fast
- Run Node.js on the host engine unless you need exact Node behavior; it starts in milliseconds.
- Use NodeRuntime or provision() on the RISC-V runner: one V8 boot, then snapshot restores.
- Prefer wasm builds of tools when they exist; they run on the wasm runner at native wasm speed.
- Host the VM in a Web Worker so long runs don't block the page.
- Bound untrusted work with
maxStepsand scriptlimits.
How this compares to other in-browser runtimes: comparisons.