quickjs-wasi
A snapshotable JavaScript runtime via WebAssembly. Runs QuickJS compiled to WASM, with the ability to snapshot the entire VM state (including pending promises) and restore it in a fresh WASM instance.
Motivation
The Workflow DevKit project implements durable function execution for TypeScript using an event-replay technique: workflow code is re-executed from the beginning on every resumption, with the full event log used as the source of truth for previously completed work. This approach has scaling limitations:
- As the event log grows, re-fetching it becomes expensive
- Replaying the full log takes increasingly longer
- There is an effective upper bound on how much work a workflow can do
- Running "forever" workflows is impractical
This project explores a fundamentally different approach: VM snapshotting. Instead of replaying from the beginning, we snapshot the JavaScript execution environment at each suspension point and restore it on resumption. The restored VM already has the correct state — only events since the last snapshot need to be fetched and applied.
Install
npm install quickjs-wasi
Usage
Basic Evaluation
Both QuickJS and JSValueHandle implement Symbol.dispose, so you can use using declarations for automatic cleanup:
import { QuickJS } from 'quickjs-wasi';
{
using vm = await QuickJS.create(wasmBytes);
using result = vm.unwrapResult(vm.evalCode('1 + 2'));
console.log(result.toNumber());
}
Working with Values
using vm = await QuickJS.create(wasmBytes);
{
using str = vm.newString('hello');
using num = vm.newNumber(42);
using big = vm.newBigInt(9007199254740993n);
vm.setProp(vm.global, 'message', str);
}
using msg = vm.unwrapResult(vm.evalCode('message'));
console.log(msg.toString());
using handle = vm.hostToHandle({ x: 1, y: [2, 3] });
const dumped = vm.dump(handle);
const value = vm.evalCode('1 + 2').consume(h => h.toNumber());
Host Functions
Register JavaScript functions backed by host (Node.js) callbacks:
using vm = await QuickJS.create(wasmBytes);
{
using add = vm.newFunction('add', (...args) => {
return vm.newNumber(args[0].toNumber() + args[1].toNumber());
});
vm.setProp(vm.global, 'add', add);
}
using result = vm.unwrapResult(vm.evalCode('add(3, 4)'));
console.log(result.toNumber());
Promises and Async Host Functions
Bridge async host operations into the QuickJS sandbox:
using vm = await QuickJS.create(wasmBytes);
{
using dnsResolve = vm.newFunction('dnsResolve', (...args) => {
const hostname = args[0].toString();
const deferred = vm.newPromise();
dns.resolve4(hostname).then(
(addresses) => {
deferred.resolve(vm.newString(addresses[0]));
vm.executePendingJobs();
},
(err) => {
deferred.reject(vm.newError(err));
vm.executePendingJobs();
}
);
return deferred.handle;
});
vm.setProp(vm.global, 'dnsResolve', dnsResolve);
}
Error Handling
using vm = await QuickJS.create(wasmBytes);
try {
vm.unwrapResult(vm.evalCode('throw new TypeError("bad")'));
} catch (err) {
console.log(err.name);
console.log(err.message);
console.log(err.stack);
}
{
using errHandle = vm.newError(new RangeError('out of bounds'));
vm.setProp(vm.global, 'hostError', errHandle);
}
Deterministic Execution
The wasi.now option controls Date.now(), new Date(), and — crucially — the Math.random() PRNG seed. QuickJS uses a xorshift64* PRNG that is seeded once from the clock value during context creation. The now() callback is not called on every Math.random() invocation — it seeds the PRNG at startup, and subsequent calls are purely deterministic from that seed.
This means two VMs created with the same now() value will produce identical Math.random() sequences:
const fixedTime = () => BigInt(1700000000000) * 1_000_000n;
using vm1 = await QuickJS.create({ wasm: wasmBytes, wasi: { now: fixedTime } });
using vm2 = await QuickJS.create({ wasm: wasmBytes, wasi: { now: fixedTime } });
vm1.evalCode('Math.random()').consume(h => h.toNumber());
vm2.evalCode('Math.random()').consume(h => h.toNumber());
The time can also be advanced between calls for realistic behavior:
let currentTime = 1700000000000n;
using vm = await QuickJS.create({
wasm: wasmBytes,
wasi: {
now: () => currentTime * 1_000_000n,
},
});
vm.evalCode('Date.now()').consume(h => h.toNumber());
currentTime += 1000n;
vm.evalCode('Date.now()').consume(h => h.toNumber());
Memory Limits
Restrict how much memory the QuickJS runtime can allocate. When exceeded, allocations fail and surface as JS exceptions:
using vm = await QuickJS.create({
wasm: wasmBytes,
memoryLimit: 4 * 1024 * 1024,
});
vm.evalCode(`
try {
const huge = new Array(10000000).fill("x".repeat(1000));
} catch (e) {
console.log(e.message); // allocation failure
}
`);
The limit is re-applied after QuickJS.restore(), so you can use a different limit for restored VMs than the original.
Interrupt Handler
Prevent infinite loops and enforce execution timeouts:
const start = Date.now();
using vm = await QuickJS.create({
wasm: wasmBytes,
interruptHandler: () => {
return Date.now() - start > 5000;
},
});
const result = vm.evalCode('while (true) {}');
result.isException;
result.dispose();
vm.evalCode('1 + 2').consume(h => h.toNumber());
The handler is called approximately once per JS bytecode instruction, so it should be fast. When it returns true, the current execution is interrupted and returns an exception result. The VM remains usable after an interrupt.
Snapshot and Restore
The key differentiator — snapshot the entire VM state and restore it later:
let snapshot: Snapshot;
{
using vm = await QuickJS.create(wasmBytes);
vm.unwrapResult(vm.evalCode(`
globalThis.counter = 0;
let __resolve;
globalThis.pendingWork = new Promise(r => { __resolve = r; });
globalThis.__resolve = __resolve;
globalThis.pendingWork.then(value => {
globalThis.counter = value;
});
`)).dispose();
vm.executePendingJobs();
snapshot = vm.snapshot();
}
const bytes = QuickJS.serializeSnapshot(snapshot);
await storage.put('snapshots/run-123', bytes);
const loaded = await storage.get('snapshots/run-123');
const restored = QuickJS.deserializeSnapshot(loaded);
{
using vm = await QuickJS.restore(restored, wasmBytes);
using resolve = vm.global.getProp('__resolve');
using arg = vm.newNumber(42);
vm.callFunction(resolve, vm.undefined, arg).dispose();
vm.executePendingJobs();
using counter = vm.global.getProp('counter');
console.log(counter.toNumber());
}
Host Callbacks After Restore
Host functions registered with newFunction() are assigned integer IDs that get baked into the snapshot. After restoring, re-register the callbacks:
let snapshot: Snapshot;
{
using vm = await QuickJS.create(wasmBytes);
using fn = vm.newFunction('hostAdd', (...args) => {
return vm.newNumber(args[0].toNumber() + args[1].toNumber());
});
vm.setProp(vm.global, 'hostAdd', fn);
snapshot = vm.snapshot();
}
{
using vm = await QuickJS.restore(snapshot, wasmBytes);
vm.registerHostCallback(1, (...args) => {
return vm.newNumber(args[0].toNumber() + args[1].toNumber());
});
using result = vm.unwrapResult(vm.evalCode('hostAdd(100, 200)'));
console.log(result.toNumber());
}
Native WASM Extensions
Load C-based extensions compiled as WASM shared libraries. Extensions link directly against the QuickJS C API with zero marshalling overhead — they share the same linear memory and can register custom classes, prototypes, and globals.
import { QuickJS } from 'quickjs-wasi';
import { readFileSync } from 'fs';
const urlExt = readFileSync('./extensions/url/url.so');
using vm = await QuickJS.create({
extensions: [{ name: 'url', wasm: urlExt }],
});
using result = vm.unwrapResult(vm.evalCode(`
const url = new URL('https://example.com:8080/api?key=value#section');
url.hostname // 'example.com'
`));
Extensions survive snapshot/restore — provide the same extensions when restoring:
const snapshot = vm.snapshot();
using vm2 = await QuickJS.restore(snapshot, {
extensions: [{ name: 'url', wasm: urlExt }],
});
See EXTENSIONS.md for how to build extensions, how dynamic linking works, and known limitations.
API Reference
QuickJS (VM Instance)
QuickJS.create(options?) | Create a fresh VM instance |
QuickJS.restore(snapshot, options?) | Restore a VM from a snapshot |
QuickJS.serializeSnapshot(snapshot) | Serialize a snapshot to a versioned binary Uint8Array |
QuickJS.deserializeSnapshot(data) | Deserialize a snapshot from a binary Uint8Array |
vm.evalCode(code, filename?) | Evaluate JS code, returns JSValueHandle |
vm.unwrapResult(handle) | Returns the handle if not an exception, otherwise throws |
vm.callFunction(fn, this, ...args) | Call a QuickJS function |
vm.executePendingJobs() | Drain the promise microtask queue |
vm.newString(str) | Create a string value |
vm.newNumber(num) | Create a number value |
vm.newBigInt(val) | Create a BigInt value |
vm.newObject() | Create an empty object |
vm.newArray() | Create an empty array |
vm.newSymbolFor(description) | Create a global symbol (Symbol.for(description)) |
vm.newArrayBuffer(data) | Create an ArrayBuffer from host ArrayBuffer or Uint8Array |
vm.newUint8Array(data) | Create a Uint8Array from host Uint8Array |
vm.newFunction(name, callback) | Create a function backed by a host callback |
vm.newPromise() | Create a Deferred (promise + resolve/reject) |
vm.newError(messageOrError) | Create an Error from a string or native Error |
vm.resolvePromise(handle) | Await a QuickJS promise from the host side |
vm.setProp(obj, key, value) | Set a property (key: string or handle, including symbols) |
vm.getProp(obj, key) | Get a property using a handle key (including symbols) |
vm.typeof(handle) | Get the typeof as a string |
vm.dump(handle) | Convert a QuickJS value to a host value |
vm.hostToHandle(value) | Convert a host value to a QuickJS handle |
vm.snapshot() | Capture the entire VM state (including extension metadata) |
vm.registerHostCallback(id, fn) | Re-register a host callback after restore |
vm.dispose() | Free the VM |
vm[Symbol.dispose]() | Same as dispose() — enables using vm = ... |
QuickJSOptions
wasm | WASM module bytes or pre-compiled WebAssembly.Module |
wasi | Custom WASI function implementations (now, stdout) |
memoryLimit | Maximum memory the QuickJS runtime can allocate (bytes) |
interruptHandler | Callback to interrupt execution (return true to stop) |
extensions | Array of ExtensionDescriptor objects — native WASM extensions to load |
ExtensionDescriptor
name | Identifier string (used in snapshot metadata) |
wasm | WASM bytes (BufferSource) or pre-compiled WebAssembly.Module |
initFn? | Init function name (default: qjs_ext_${name}_init) |
Cached Properties
These are singleton handles — do not dispose them:
vm.global | The global object |
vm.undefined | undefined |
vm.null | null |
vm.true | true |
vm.false | false |
JSValueHandle
handle.isException | true if this is an exception result |
handle.isUndefined | true if this is undefined |
handle.isNull | true if this is null |
handle.promiseState | 0 pending, 1 fulfilled, 2 rejected |
handle.toNumber() | Extract as a number |
handle.toBigInt() | Extract as a bigint |
handle.toString() | Extract as a string |
handle.toArrayBuffer() | Extract as an ArrayBuffer (copy from WASM memory) |
handle.toUint8Array() | Extract as a Uint8Array (copy from WASM memory) |
handle.getProp(name) | Get a property by name |
handle.setProp(name, value) | Set a property by name |
handle.consume(fn) | Call fn(handle), then dispose, return result |
handle.dup() | Duplicate the handle (increment refcount) |
handle.dispose() | Free the handle |
handle[Symbol.dispose]() | Same as dispose() — enables using handle = ... |
Deferred (from vm.newPromise())
deferred.handle | The QuickJS promise object |
deferred.settled | Host Promise<void> that resolves on settlement |
deferred.resolve(handle) | Resolve the promise with a QuickJS value |
deferred.reject(handle) | Reject the promise with a QuickJS value |
Data Marshalling
dump() and hostToHandle() automatically convert values between the host and the QuickJS VM. The following types are supported:
undefined | undefined | undefined | undefined |
null | null | null | null |
boolean | boolean | boolean | boolean |
number | number | number | number |
string | string | string | string |
bigint | BigInt | bigint | bigint |
Symbol.for() | global Symbol | Symbol.for(description) | Symbol.for(description) |
Error | Error | Error (with name, message, stack) | Error |
Array | Array | Array (recursive) | Array (recursive) |
ArrayBuffer | ArrayBuffer | ArrayBuffer (copy) | ArrayBuffer |
Uint8Array | Uint8Array | Uint8Array (copy) | Uint8Array |
| Other typed arrays | typed array | Corresponding typed array (copy) | ArrayBuffer (via view) |
Promise | Promise | — | QuickJS Promise (bridged via Deferred) |
| Plain object | Object | Record<string, unknown> (recursive, own enumerable keys) | Object (recursive) |
Notes:
- Global symbols (
Symbol.for()) round-trip as real host Symbol values via Symbol.for(description)
- Local (anonymous) symbols dump as
undefined and throw if passed to hostToHandle()
- Functions dump as
undefined (cannot be meaningfully serialized)
- Circular and shared references are preserved —
dump() returns the same host object for the same QuickJS object pointer
- Only own enumerable string properties are included when dumping objects
- Binary data is always copied between host and WASM memory — there is no zero-copy view API
dump() for typed arrays determines the host constructor from bytes-per-element (1 → Uint8Array, 2 → Uint16Array, 4 → Uint32Array, 8 → Float64Array)
How It Works
The Core Insight
WebAssembly linear memory is a flat byte array. Everything QuickJS allocates — the runtime struct, all contexts, all JS objects, the GC heap, the atom table, the promise job queue, pending promises — lives in this linear memory. There are no external pointers, file handles, or OS resources. When you copy the memory wholesale to a new WASM instance, all internal pointer relationships are preserved because they reference the same linear address space.
One VM = One WASM Instance
Unlike quickjs-emscripten which has a two-level model (QuickJSWASMModule → QuickJSContext), quickjs-wasm uses a simpler one-level model: each QuickJS.create() call instantiates its own WASM module with its own linear memory, runtime, and context. This gives stronger isolation (no shared memory between VMs) and makes snapshotting clean — one instance, one context, one snapshot.
Architecture
Host (Node.js / Deno / Bun / Browser)
|
+-- QuickJS class (ts/index.ts)
| |-- evalCode(), callFunction(), newFunction(), ...
| |-- snapshot() -> Snapshot { memory, stackPointer, runtimePtr, contextPtr }
| +-- restore(snapshot) -> QuickJS
|
+-- WASI Shim (ts/wasi-shim.ts)
| |-- clock_time_get, fd_write, random_get
| +-- fd_close, fd_fdstat_get, fd_seek (stubs)
|
+-- quickjs.wasm (1.4 MB)
|-- QuickJS-NG engine
+-- C interface layer (c/interface.c)
|-- Lifecycle, eval, value creation/extraction
|-- Host callback trampoline (imported host_call)
+-- Snapshot support (get/set runtime and context pointers)
Host Callback Mechanism
When vm.newFunction() is called, an integer ID is allocated and a QuickJS C function is created via JS_NewCFunctionData2 with that ID stored as function data. When QuickJS code calls the function, the C trampoline extracts the ID and calls the imported host_call(func_id, this_ptr, argc, argv_ptr) function, which dispatches to the registered host callback by ID.
This design survives snapshot/restore: the ID is stored in QuickJS's heap (part of the snapshot), and after restore, registerHostCallback(id, fn) re-maps the ID to a new host function.
Implications for Durable Workflows
| Resumption cost | O(n) — replay full event log | O(1) — restore snapshot + fetch delta |
| Event log growth | Unbounded, all events needed | Can be trimmed after snapshot |
| Long-running workflows | Impractical at scale | No degradation over time |
| State representation | Implicit (derived from log) | Explicit (WASM memory snapshot) |
| Snapshot size | N/A | ~256 KB baseline, grows with JS heap |
| Determinism requirement | Yes (seeded PRNG, frozen time) | No (state is captured, not re-derived) |
Development
Prerequisites
- wasi-sdk (tested with v30) — set
WASI_SDK env var or defaults to /tmp/wasi-sdk
- Node.js >= 22
- pnpm
Building Locally
git clone --recursive https://github.com/vercel-labs/quickjs-wasm.git
cd quickjs-wasm
curl -sL "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-30/wasi-sdk-30.0-arm64-macos.tar.gz" \
| tar xz -C /tmp --strip-components=1 --one-top-level=wasi-sdk
pnpm install
pnpm run build
pnpm test
Technical Details
WASM Binary
- Built from quickjs-ng (MIT license)
- Compiled with wasi-sdk targeting
wasm32-wasip1 in reactor mode
- 1.4 MB uncompressed
- 7 WASM imports: 6 WASI functions + 1
env.host_call for host callbacks
- Exports
memory and __stack_pointer for snapshot support
What Gets Snapshotted
The snapshot captures the entire WASM linear memory, which contains:
- The
JSRuntime struct (GC state, job queue, module loader state)
- The
JSContext struct (global object, intrinsics, atom table)
- All JS objects (via QuickJS's GC heap)
- The promise job queue (pending
.then callbacks)
- The string intern table (atoms)
- The
dlmalloc heap metadata
- The C interface's
static JSRuntime *rt and static JSContext *ctx globals
- Host callback IDs stored in function data
Plus the __stack_pointer WASM global (a single i32).
Limitations and Future Work
- Snapshot size: Snapshots capture the entire WASM linear memory (~256 KB baseline, grows with heap). Use
serializeSnapshot() to get a binary buffer, then apply your own compression (gzip/zstd) — the memory compresses very well due to large zero regions.
- Stack size limit: QuickJS-ng disables
JS_SetMaxStackSize on WASI, so deep recursion causes a WASM trap (not a catchable exception).
- ES Modules: Only script-mode eval is supported.
import/export and module loaders are not yet wired through.
- Extension ABI: Native WASM extensions use an experimental dynamic linking ABI that is not yet stabilized. All extensions must be compiled with the same wasi-sdk version as the main module. See EXTENSIONS.md for details.
Browser Usage
quickjs-wasi works in browsers — the TypeScript API uses only the standard WebAssembly API and the WASI shim is environment-agnostic. The only Node.js-specific code is the default WASM loading fallback (which uses node:fs). In the browser, pass the WASM bytes directly:
import { QuickJS } from 'quickjs-wasi';
const response = await fetch('/quickjs.wasm');
const wasmModule = await WebAssembly.compileStreaming(response);
using vm = await QuickJS.create({ wasm: wasmModule });
See examples/browser/ for a complete Vite demo app.