I love Node.js. It's why I can ship a backend in an afternoon.
But I also write firmware and deal with edge hardware — OpenWrt routers, MIPS
boards, small ARM gateways. And on that class of device, Node.js is often just
too much: a 100 MB+ runtime, high memory use, and a cold start you can feel.
So I built xuan (玄) — a lightweight JavaScript runtime for IoT and embedded
devices. It's built on QuickJS and forked from
tjs / txiki.js. It's MIT licensed and on
GitHub here: https://github.com/topelinux/xuan.
This post is about why it exists, how it's put together, and how to try it.
The problem with "just use Node"
On a server, none of Node's costs matter. On a $10 board with 64 MB of RAM, they
all do:
- Binary size — you don't want a 100 MB runtime on a device with a small flash partition.
- Memory — resident memory is at a premium.
- Startup — if your program runs occasionally (a cron-like sensor task, a request handler on a gateway), cold start dominates.
I wanted to run JavaScript — and ideally a good chunk of the npm ecosystem — in
that environment, with a runtime measured in single-digit megabytes.
What xuan is
xuan embeds QuickJS (a small, fast ES2020+ engine by Fabrice Bellard) and
adds the pieces a real application needs:
- a Node.js-compatible API layer (so many existing npm packages run with little or no change)
- built-in IoT modules: MQTT, serial port, and FFI
- standalone compilation: turn a script into a single self-contained binary
- cross-compilation to x86, ARM, MIPS and RISC-V
It's a fork of txiki.js, which already had an excellent QuickJS + Node-compat
foundation. My focus is the IoT/embedded angle and the built-in device modules.
Built-in modules, not npm-installed ones
On constrained devices, pulling native modules at runtime is painful. So the
ones you actually need are built in:
- MQTT — publish/subscribe, the lingua franca of IoT
- SerialPort — talk to MCUs and modems
- FFI — call into C libraries directly, so you can reuse existing code
- GPIO / I2C / SPI — in progress
A minimal MQTT client looks like normal Node code:
const mqtt = require('mqtt');
const client = mqtt.connect('mqtt://broker.emqx.io');
client.on('message', (topic, msg) => {
console.log(topic, msg.toString());
});
client.on('connect', () => {
client.subscribe('iot/device/001/#', { qos: 1 });
setInterval(() => {
client.publish('iot/device/001/status', 'online', { qos: 1 });
}, 5000);
});
And FFI lets you reach for C when you need to:
import FFI from 'tjs:ffi';
const libc = new FFI.Lib(FFI.Lib.LIBC_NAME);
const atoi = new FFI.CFunction(libc.symbol('atoi'), FFI.types.sint, [FFI.types.string]);
console.log(atoi.call('123')); // 123
Standalone binaries with xuan compile
You can also embed your app into the runtime and ship one file:
./build/xuan compile app.js myapp
./myapp # no Node.js, no runtime install required
That's the deployment story I wanted for devices: build once on your machine,
copy one binary onto the target.
Rough numbers
On a MacBook Air M1 (executable size and cold start only — this is not a
resident-memory benchmark):
| Runtime | Cold start (mean) | Binary size |
|---|---|---|
| xuan | 5.37 ms | 6.2 MB |
| bun | 8.82 ms | 58 MB |
| Node.js | 20.29 ms | 112 MB |
220 runs each, randomly interleaved, -e "0", wall-clock from process start to
exit. Treat it as a rough data point, not a victory lap.
Those numbers are on a fast desktop CPU; the interesting part for embedded folks
is the shape — a single-digit-megabyte runtime with millisecond startup.
How it's put together
┌─────────────────────────────────────┐
│ xuan Runtime │
├─────────────────────────────────────┤
│ Node.js Compatible API Layer │
├─────────────────────────────────────┤
│ IoT Native Modules (MQTT/Serial/FFI)│
├─────────────────────────────────────┤
│ QuickJS Engine │
├─────────────────────────────────────┤
│ Embedded System (Linux/RTOS) │
└─────────────────────────────────────┘
JavaScript sources are bundled (esbuild) and compiled into C, then embedded into
the binary. Local changes to the bundled dependencies (libwebsockets, QuickJS,
libuv, mimalloc) are kept as patches and applied at build time, so a clean clone
builds reproducibly.
Build and run
git clone https://github.com/topelinux/xuan.git
cd xuan
git submodule update --init --recursive --force
npm install # esbuild etc. — needed before make
make # -> ./build/xuan
./build/xuan run app.js
What works, and what's next
Working today: the Node-compat layer, MQTT, serial port, FFI, xuan compile,
and cross-compilation. Next up: GPIO, I2C and SPI, plus widening Node API
coverage.
It's early, and I'd genuinely value feedback — especially from people shipping
on constrained hardware. Which boards, which Node APIs, which device modules
would you need first?
- Repo: https://github.com/topelinux/xuan
- License: MIT
Credit where it's due: the foundation is txiki.js
by Saúl Ibarra Corretgé, and the engine is QuickJS
by Fabrice Bellard.
Top comments (1)
Great write-up. The structured step-by-step reasoning made following along very engaging. Keep up the awesome work!