WebAssembly
quickjs-oxide compiles to wasm32-unknown-unknown. The quickjs-oxide-web crate (apps/web) wraps the engine in a small wasm-bindgen API, and the browser runs it like any other WebAssembly module. JavaScript you hand it runs inside quickjs-oxide, not in the browser's own eval or Function.
This page covers building the package, the JavaScript API it exports, the browser host, and a worked example: the Mandelbrot demo on the homepage.
Get the package
Each GitHub release carries quickjs-oxide-wasm-vVERSION.tar.gz, with two wasm-bindgen builds of the same module:
| Directory | wasm-bindgen target | Use it from |
|---|---|---|
web/ |
web |
ES modules in the browser |
nodejs/ |
nodejs |
Node.js require |
The homepage demo uses a third target, no-modules, which suits a classic Web Worker loaded with importScripts. To get that build, or any build of your own checkout, build from source.
Build from source
The build uses the toolchain pinned in rust-toolchain.toml (Rust 1.99.0) and the wasm-bindgen CLI at exactly the version the crate pins, 0.2.126:
rustup target add --toolchain 1.99.0 wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.126 --lockedBuild the crate with the web profile, then generate the JavaScript glue:
cargo build --locked --profile web --target wasm32-unknown-unknown -p quickjs-oxide-web
wasm-bindgen target/wasm32-unknown-unknown/web/quickjs_oxide_web.wasm \
--out-dir pkg --out-name quickjs_oxide_web --target no-modules --no-typescriptThis writes pkg/quickjs_oxide_web.js (the glue) and pkg/quickjs_oxide_web_bg.wasm (the engine). Pass --target web or --target nodejs instead for the module or Node.js forms.
The web profile is defined in the workspace Cargo.toml. It inherits release and optimizes for size:
[profile.web]
inherits = "release"
opt-level = "s"
lto = true
codegen-units = 1
strip = true
panic = "abort"scripts/web/build-site.sh runs the same two steps for the published site, then builds the homepage and these docs around the result. It first checks that the pinned toolchain, the wasm32-unknown-unknown target and wasm-bindgen 0.2.126 are installed, and prints the install command for whatever is missing. The build reads QUICKJS_OXIDE_COMMIT and reports it as the build commit; without it the commit is local.
The JavaScript API
The module exports two functions and one class. With the no-modules target they hang off the global wasm_bindgen; with web and nodejs they are named exports.
| Export | Returns | What it does |
|---|---|---|
evaluate(source) |
result | Runs one script in a fresh runtime that is dropped afterwards |
engine_metadata() |
metadata | Describes the loaded build |
new Session() |
Session |
Creates one runtime that stays alive |
session.eval(source) |
result | Runs a script in that session's runtime |
Results
evaluate and session.eval return a plain object with three fields:
{ ok: boolean, kind: string, text: string }kind |
ok |
text |
|---|---|---|
undefined, null, boolean, number, bigint, string, symbol |
true |
The completion value as a string (42, 10n, Symbol(tag)) |
object |
true |
[object Object] |
exception |
false |
The thrown error as Name: message, or the thrown value |
engine-error |
false |
The message of an engine error that is not a JavaScript exception |
The completion value of a script is the value of its last expression statement. An object comes back as [object Object], so return structured data as a string, for example with JSON.stringify.
Before returning, both calls run every pending job, so the promise reactions the script queued have run.
Sessions
A Session holds one runtime and its one realm. Globals, functions and objects defined by one eval are there for the next, so a host can load a script once and call into it many times:
importScripts("./pkg/quickjs_oxide_web.js");
async function main() {
await wasm_bindgen({ module_or_path: "./pkg/quickjs_oxide_web_bg.wasm" });
const session = new wasm_bindgen.Session();
session.eval("function add(a, b) { return a + b; }");
// { ok: true, kind: "undefined", text: "undefined" }
session.eval("add(40, 2)");
// { ok: true, kind: "number", text: "42" }
session.eval("nope");
// { ok: false, kind: "exception", text: "ReferenceError: 'nope' is not defined" }
}
main();evaluate(source) builds a fresh runtime for every call, so nothing carries over between calls.
Metadata
engine_metadata() returns the identity of the loaded build:
{ engine: "quickjs-oxide", crateVersion: "0.3.1", quickjsTarget: "QuickJS 2026-06-04",
buildCommit: "local", canBlock: false }buildCommit is the value of QUICKJS_OXIDE_COMMIT at build time. canBlock is the runtime's blocking policy: it is false, so Atomics.wait throws a TypeError instead of blocking the worker.
The browser host
The engine reaches its environment through the Host trait (Host services). The quickjs-oxide-web-host crate (adapters/web) implements it as WebHost:
| Host service | WebHost |
|---|---|
Clock (Date.now, new Date()) |
The browser's Date.now() |
| Local time zone offset | The browser's Date.prototype.getTimezoneOffset() |
Math.random seed |
Two calls to the browser's Math.random() |
Output (print, console.log) |
Discarded |
| File reads | None |
Output is discarded, so a script reports back through its completion value. The demo below returns a string from each call.
apps/web creates every runtime with Runtime::with_host(WebHost, options) and turns blocking off with set_can_block(false). The engine measures how deep native code re-enters JavaScript from stack addresses, and in WebAssembly those addresses belong to linear memory, not to the browser's machine stack. The web runtime therefore sets two limits in RuntimeOptions:
| Option | Value | Purpose |
|---|---|---|
native_depth_limit |
600 | Nested re-entries from native code into JavaScript |
native_stack_budget |
512 KiB | Half of the 1 MiB linear-memory stack that wasm-ld reserves |
Deep recursion through callbacks, getters, proxy traps or generators ends in a catchable InternalError: stack overflow. Errors and limits covers the other limits.
Run it in a Web Worker
A script can run for a long time, and while it runs the thread that called eval is blocked. Run the engine in a dedicated Web Worker so the page keeps drawing and handling input, and talk to it with postMessage.
The homepage demo splits the work three ways:
| File | Runs in | Role |
|---|---|---|
mandelbrot.js |
quickjs-oxide | Computes the image, one band of rows per call |
worker.js |
The Web Worker | Loads quickjs-oxide, calls into mandelbrot.js, posts each band |
paint.js |
The page | Paints each band onto a canvas |
The script quickjs-oxide runs
mandelbrot.js defines two functions and some globals. The host calls setView once per image, then renderBand for each band. It renders in passes: every 8th pixel, then every 4th, 2nd and finally all of them. Each call returns its band as a string with one character per point, so the result crosses the WebAssembly boundary as the text of a string completion. A global, iterations, counts the work done.
// mandelbrot.js: the part quickjs-oxide runs.
// The host calls setView() once, then renderBand() pass by pass: first every
// 8th pixel, then every 4th, 2nd and finally all of them. Each pixel is
// computed exactly once; a coarse pass is painted as blocks until the finer
// passes fill it in.
//
// Every point comes back as one character: 0 inside the set, 1-255 a shade,
// 256 already computed in an earlier pass.
var KEEP = 256;
var view = null;
var iterations = 0;
function setView(width, height, cx, cy, span, maxIter, firstStep) {
view = { width: width, height: height, cx: cx, cy: cy,
scale: span / width, maxIter: maxIter, firstStep: firstStep };
iterations = 0;
}
// The points of pass `step` in rows y0 .. y0 + rows - 1.
function renderBand(step, y0, rows) {
var w = view.width, h = view.height, maxIter = view.maxIter;
var scale = view.scale, coarse = step * 2, out = [];
var row = new Array(Math.ceil(w / step));
for (var y = y0; y < y0 + rows && y < h; y += step) {
var ci = view.cy + (y - h / 2) * scale;
var oldRow = step < view.firstStep && y % coarse === 0;
for (var x = 0, i = 0; x < w; x += step, i++) {
if (oldRow && x % coarse === 0) {
row[i] = KEEP;
continue;
}
var cr = view.cx + (x - w / 2) * scale;
var zr = 0, zi = 0, zr2 = 0, zi2 = 0, n = 0;
while (n < maxIter && zr2 + zi2 <= 256) {
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr;
zi2 = zi * zi;
n++;
}
iterations += n;
row[i] = n === maxIter ? 0 : shade(n, zr2 + zi2);
}
out.push(String.fromCharCode.apply(null, row));
}
return out.join("");
}
// Smooth escape count, folded into bands that brighten toward the set.
function shade(n, m) {
var smooth = n + 1 - Math.log(Math.log(m) / 2) / Math.LN2;
var f = (Math.sqrt(Math.max(0, smooth)) * 0.45) % 1;
return 1 + Math.floor((1 - Math.abs(2 * f - 1)) * 254);
}The host
worker.js loads the glue with importScripts, initializes the module, creates one Session and evaluates mandelbrot.js in it. For each job it builds small call expressions such as renderBand(8, 0, 24) and evaluates them in the same session, so the functions are compiled once. check turns a failed result into a JavaScript exception. Between bands it yields to the event loop with setTimeout, so a newer job posted by the page can replace the current one.
// worker.js: the browser host. It loads quickjs-oxide, runs mandelbrot.js
// inside it, and hands each finished band to the page, which paints it.
/* global wasm_bindgen */
"use strict";
importScripts("./pkg/quickjs_oxide_web.js");
const STEPS = [8, 4, 2, 1];
const BAND_POINTS = 8000; // about the same work per message in every pass
let session = null;
let currentJob = 0;
async function boot() {
const start = performance.now();
await wasm_bindgen({ module_or_path: "./pkg/quickjs_oxide_web_bg.wasm" });
session = new wasm_bindgen.Session(); // one runtime, kept alive
const source = await (await fetch("./mandelbrot.js")).text();
check(session.eval(source));
const meta = wasm_bindgen.engine_metadata();
postMessage({ type: "ready", loadMs: performance.now() - start,
version: meta.crateVersion, commit: meta.buildCommit });
}
function check(result) {
if (!result.ok) throw new Error(result.text);
return result.text;
}
async function render(job) {
const { id, width, height, cx, cy, span, maxIter } = job;
check(session.eval(`setView(${width}, ${height}, ${cx}, ${cy}, ${span}, ${maxIter}, ${STEPS[0]})`));
let engineMs = 0, done = 0;
for (const step of STEPS) {
const rows = step * Math.max(1, Math.floor(BAND_POINTS / Math.ceil(width / step)));
for (let y0 = 0; y0 < height; y0 += rows) {
if (id !== currentJob) return; // a newer run replaced this one
const start = performance.now();
const shades = check(session.eval(`renderBand(${step}, ${y0}, ${rows})`));
engineMs += performance.now() - start; // time inside the engine only
const iterations = Number(check(session.eval("iterations")));
done += countNew(shades);
postMessage({ type: "band", id, step, y0, rows, shades, engineMs, iterations,
progress: done / (width * height) });
await new Promise((resolve) => setTimeout(resolve, 0)); // let messages in
}
}
postMessage({ type: "done", id });
}
function countNew(shades) {
let n = 0;
for (let i = 0; i < shades.length; i++) if (shades.charCodeAt(i) !== 256) n++;
return n;
}
const booted = boot().catch((error) => postMessage({ type: "error", message: String(error.message || error) }));
onmessage = async ({ data: job }) => {
currentJob = job.id;
await booted;
if (!session) return;
try {
await render(job);
} catch (error) {
postMessage({ type: "error", id: job.id, message: String(error.message || error) });
}
};The page
paint.js runs on the page. It maps each shade character to a color and fills a block of pixels per point, so a coarse pass shows up at once and finer passes sharpen it. It knows nothing about quickjs-oxide; it reads the bands the worker posts.
// paint.js: the page. It paints each band the worker sends.
const STOPS = [
[0.00, 23, 18, 38], [0.40, 40, 26, 80], [0.50, 120, 70, 200],
[0.58, 63, 208, 232], [0.68, 255, 95, 158], [0.80, 255, 180, 92],
[0.90, 255, 210, 63], [1.00, 255, 246, 220],
];
const INSIDE = [18, 12, 33];
// Shade 0 is inside the set; shades 1-255 walk the color stops.
const LUT = Array.from({ length: 256 }, (_, shade) => {
if (shade === 0) return INSIDE;
const t = (shade - 1) / 254;
let i = 0;
while (i < STOPS.length - 2 && t > STOPS[i + 1][0]) i++;
const [t0, ...c0] = STOPS[i], [t1, ...c1] = STOPS[i + 1];
const f = Math.min(1, Math.max(0, (t - t0) / (t1 - t0)));
return c0.map((v, k) => Math.round(v + (c1[k] - v) * f));
});
// A band of pass `step` holds every step-th point of its rows; each point
// fills a step × step block until a finer pass overwrites it. 256 means the
// point was painted by a coarser pass and stays.
function paint(frame, band) {
const { width, height, data } = frame;
const { step, y0, shades } = band, cols = Math.ceil(width / step);
for (let k = 0; k < shades.length; k++) {
const shade = shades.charCodeAt(k);
if (shade === 256) continue;
const c = LUT[shade], x0 = (k % cols) * step, top = y0 + Math.floor(k / cols) * step;
for (let y = top; y < Math.min(top + step, height); y++) {
for (let x = x0; x < Math.min(x0 + step, width); x++) {
const o = (y * width + x) * 4;
data[o] = c[0]; data[o + 1] = c[1]; data[o + 2] = c[2]; data[o + 3] = 255;
}
}
}
}The page starts the worker with new Worker("worker.js"), posts a job object (id, width, height, cx, cy, span, maxIter) and calls paint for each band message on an ImageData frame it then draws to the canvas.
Where to go next
- Host services: the
Hosttrait thatWebHostimplements. - Errors and limits: exceptions, engine errors and the runtime's limits.
- Embed quickjs-oxide: the Rust API that
apps/webwraps. apps/web/src/lib.rs: the complete bindings.