A RISC-V Linux emulator in WebAssembly
At the core of userland.run is NanoVM: an RV64GC RISC-V CPU and a Linux system-call layer written in #![no_std] Rust and compiled to WebAssembly. It loads ordinary statically linked Linux ELF binaries — BusyBox, the real Node.js v25 executable, ripgrep — and runs them instruction by instruction inside a browser tab.
Real binaries, not reimplementations
Most in-browser runtimes reimplement an API (usually Node's) on top of browser primitives. NanoVM takes the other route: it emulates a CPU and enough of the Linux kernel ABI that programs compiled for riscv64gc-linux-musl run unchanged. The trade-off is speed — every guest instruction is interpreted — in exchange for fidelity: the same binary, the same behaviour, the same edge cases.
Ask the kernel what it is
These lines run through BusyBox on the emulated CPU. The machine type is the RISC-V one, whatever your laptop is.
uname -m uname -s busybox | head -n 1
riscv64 Linux BusyBox v1.36.1 (2026-02-15 09:06:52 UTC) multi-call binary.
Pipes, redirects and a little awk
A normal shell pipeline: sh forks processes, the emulator connects them with pipes, and the results land in the in-memory filesystem.
printf 'beta 3\nalpha 1\ngamma 2\n' > scores.txt
sort -k2 -n scores.txt | awk '{ print NR ". " $1 }'
wc -l < scores.txt1. alpha 2. gamma 3. beta 3
How it works
NanoVM follows the design Fabrice Bellard used for fast interpreters. The full write-up is in the RISC-V runner internals.
- One monolithic
exec()loop. Dense instruction dispatch compiles to WebAssemblybr_tablejump tables; fat LTO fuses the decoder and execution units into a single function with CPU registers held in locals. - RV64GC. The 64-bit base ISA with integer multiply/divide, atomics, single and double floating point, and 16-bit compressed instructions.
- No allocation in the hot path.
#![no_std]Rust with zero crate dependencies; maths such assqrtlowers straight to wasm opcodes. - Cooperative scheduling. The host calls
vm_step(budget); the guest returns control when the budget is spent, a process exits, or it needs file I/O — so the page stays responsive. - A thin host boundary. File I/O goes through a shared-memory request/response protocol to an in-memory POSIX filesystem, not per-instruction callbacks.
- Threads.
clone+futexgive cooperative multithreading with context switches at syscall boundaries — which Node.js's worker threads rely on.
The slim image (nano.wasm) is 2.46 MB and contains only the emulator; BusyBox, Node.js and other tools are fetched from the signed app catalog on demand. console.wasm (3.2 MB) has BusyBox built in for terminals.
The Linux syscall layer
The syscall reference lists 87 Linux system calls. Most are handled inside the wasm module; file-system calls are forwarded to the JavaScript MemFS.
| Area | Syscalls | Examples |
|---|---|---|
| File system | 23 | openat, read, write, getdents64, statx, renameat2 |
| Sockets | 14 | socket, bind, listen, accept4, connect, sendto |
| Identity & system | 13 | getuid, uname, sysinfo, getrandom, prlimit64 |
| Process & threads | 10 | clone, futex, exit_group, set_tid_address |
| Memory | 6 | brk, mmap, mremap (munmap/mprotect are no-op stubs) |
| Epoll & events | 6 | epoll_pwait, eventfd2, pipe2, ppoll |
| Signals | 6 | stubs: rt_sigaction, kill, tgkill, … |
| I/O control | 4 | ioctl, fcntl, dup, dup3 |
| Time & timers | 5 | clock_gettime, nanosleep, timerfd_create |
ramMB); Node.js needs about 1.8 GB, BusyBox is happy with 256–512 MB.Running commands from JavaScript
The SDK exposes the VM as Nano. run executes one command (whitespace-split, no shell), exec runs an exact argv, shExec runs a full shell line. Every call takes an instruction budget, maxSteps, so a runaway guest can't spin forever.
run, exec, shExec and an instruction budget
The last call is an infinite shell loop with a 5 million instruction budget: it is cut off and reports exit code -1.
const a = await nano.run("echo one two");
print("run:", a.stdout.trim(), "exit", a.exitCode);
const b = await nano.exec(["printf", "%s|", "a b", "c"]);
print("exec:", b.stdout);
const c = await nano.shExec("seq 1 5 | awk '{ s += $1 } END { print s }'");
print("shExec:", c.stdout.trim());
const d = await nano.shExec("while :; do :; done", { maxSteps: 5_000_000 });
print("budget exhausted, exit", d.exitCode);run: one two exit 0 exec: a b|c| shExec: 15 budget exhausted, exit -1
Real ELF binaries from the catalog
Anything built as a static riscv64gc-linux-musl executable can run. The catalog publishes signed builds of common tools; installing one verifies every chunk against the catalog key before it touches the VFS, then the binary runs on the emulator.
Install the ripgrep ELF and run it on the VM
This is ripgrep 14.1.0 compiled for RV64GC — not the wasm build from WASI apps.
rg --version rg -n 'emulat' notes.txt
installed ripgrep@14.1.0 (1 file, verified) ripgrep 14.1.0 (rev e50df40a19) features:-simd-accel,-pcre2 PCRE2 is not available in this build of ripgrep. 1:a cpu emulator 3:emulating rv64gc
Node.js on the emulator
The catalog's node package is the upstream Node.js v25.4.0 binary built for riscv64. Running it on the VM is the fidelity path — the real V8, the real libuv, native addons — and also the slowest: expect V8 to take a while to start under emulation. For everyday scripts the SDK can route node to the browser's own JS engine instead (see engines).
process.arch on the real Node binary
With needs: ["node-vm"] this sample installs node from the catalog and runs it on the RISC-V VM with a 1.8 GB guest. The first run downloads Node and starts V8 under emulation, so it takes longer than the other samples.
const os = require("os");
console.log("arch:", process.arch, "platform:", process.platform);
console.log("node:", process.version, "endianness:", os.endianness());arch: riscv64 platform: linux node: v25.4.0 endianness: LE
An HTTP server on the emulated CPU
The same Node binary serving HTTP from inside the VM. Requests from the preview iframe are injected as connections into the guest's socket layer (see dev servers).
const http = require("http");
http
.createServer((req, res) => {
res.setHeader("content-type", "text/html");
res.end("<h1>served by node on " + process.arch + "</h1>");
})
.listen(8080, () => console.log("listening on 8080"));served by node on riscv64
import { createNano } from "@userland-run/nano-sdk";
const nano = await createNano({ image: { wasm: "/nano/nano.wasm" }, ramMB: 1800 });
await nano.installApp("node"); // riscv64 ELF, signature-verified, OPFS-cached
nano.fs.writeFile("/app/main.js", "console.log(process.arch)");
const r = await nano.node(["/app/main.js"]); // engines.node defaults to "vm"
console.log(r.stdout); // riscv64Honest limits
- Speed. An interpreter is much slower than native code or a JIT. Shell work and short scripts feel instant; starting V8 under emulation takes noticeable time. See performance.
- Memory. One wasm linear memory, at most 2 GB for guest RAM plus the image.
- Signals are stubs and
munmap/mprotectare no-ops; programs that depend on real signal delivery or memory protection behave differently. - Networking goes through the host (
fetchfor outbound HTTP, an injected-connection bridge for inbound) — see networking.
FAQ
Why RISC-V and not x86?
RV64GC is a small, regular instruction set that decodes quickly and maps well onto a dense WebAssembly dispatch loop, and mainstream toolchains (GCC, LLVM, Rust, Go) target riscv64 Linux directly.
Can I run my own binary?
Yes, if it is a statically linked riscv64 Linux executable that stays within the implemented syscalls. Write it into the VFS with nano.fs.writeFile(path, bytes, 0o755) and run it, or publish it to the catalog.
Is it a full system emulator like QEMU?
No. NanoVM is a user-mode emulator: it runs Linux programs and implements the kernel's system-call interface itself, so there is no guest kernel to boot.
Does it work offline?
The emulator is a static wasm file, so yes once it and any binaries you need are cached. The bundled build (make build-full) embeds BusyBox, Node.js and the dev tools for fully offline use.