Running User Scripts in a QuickJS Wasm Sandbox

This page answers one task: users of your application write small scripts — spreadsheet formulas, workflow rules, data transforms, game mods — and you need to run them in the browser without letting them read cookies, call fetch, touch the DOM, freeze the page or exhaust memory. Running them with eval or new Function gives them full access to the page; you want a real sandbox.

Prerequisites

  • [ ] An npm setup with quickjs-emscripten (or a similar QuickJS Wasm build).
  • [ ] A clear list of what scripts are allowed to do — the API you will expose.
  • [ ] Limits for CPU time and memory per script run.

Why a JavaScript engine inside Wasm is a sandbox

The browser’s own JavaScript engine gives scripts on the page the page’s full authority: same origin, same cookies, same DOM. Isolating untrusted code inside the same engine is notoriously hard — iframes with sandbox and Web Workers help, but each comes with its own escape routes and lifecycle complexity. QuickJS is a small, complete JavaScript engine; compiled to WebAssembly, it runs inside a Wasm instance. Scripts evaluated in it see only the QuickJS global object — no window, no document, no fetch, nothing from the host page — unless the host explicitly injects functions. Their memory is the Wasm instance’s linear memory, and they cannot read anything outside it.

The isolation therefore comes from WebAssembly’s memory safety plus QuickJS’s separate heap; the authority comes only from what you choose to expose. Limits come from QuickJS’s own memory accounting and interrupt handler, which the host controls.

Layers of the QuickJS Wasm sandbox The page's JavaScript holds full authority. It creates a QuickJS runtime inside a Wasm instance, with its own heap and limits, and injects a small explicit API. User scripts run inside QuickJS and can reach only that API, while memory limits and an interrupt handler bound their resource use. host page (full authority) DOM, cookies, fetch Wasm instance linear memory boundary QuickJS runtime + context separate heap, limits injected API only what you expose user script no ambient access

Step 1 — create a runtime with limits

import { getQuickJS } from "quickjs-emscripten";

const QuickJS = await getQuickJS();
const runtime = QuickJS.newRuntime();
runtime.setMemoryLimit(16 * 1024 * 1024);          // 16 MB heap for scripts
runtime.setMaxStackSize(512 * 1024);
let deadline = 0;
runtime.setInterruptHandler(() => Date.now() > deadline);   // return true to interrupt
const vm = runtime.newContext();

The interrupt handler is called periodically while scripts run; returning true aborts execution with an InternalError: interrupted. The memory limit makes allocations beyond 16 MB fail inside QuickJS with an out-of-memory error, without affecting the page.

Step 2 — expose a minimal API

Inject only the functions scripts need, with arguments converted explicitly:

const getCell = vm.newFunction("getCell", (refHandle) => {
  const ref = vm.getString(refHandle);
  const value = sheet.read(ref);                  // host-side lookup, validated
  return typeof value === "number" ? vm.newNumber(value) : vm.newString(String(value ?? ""));
});
vm.setProp(vm.global, "getCell", getCell);
getCell.dispose();

QuickJS values are handles that must be disposed when no longer needed — the library tracks them and warns about leaks in debug mode. Keep the API small and data-oriented: functions that return values, not objects that expose host internals. Never pass host objects, DOM nodes or functions with broad authority into the sandbox.

Step 3 — evaluate with a deadline and read the result

function runFormula(source, timeoutMs = 50) {
  deadline = Date.now() + timeoutMs;
  const result = vm.evalCode(source, "formula.js");
  if (result.error) {
    const err = vm.dump(result.error);
    result.error.dispose();
    return { ok: false, error: String(err?.message ?? err) };
  }
  const value = vm.dump(result.value);            // converts to a plain JS value
  result.value.dispose();
  return { ok: true, value };
}

runFormula(`getCell("A1") * 2 + getCell("B2")`);
runFormula(`while (true) {}`);                      // → { ok: false, error: "interrupted" }

vm.dump converts QuickJS values to plain host values (numbers, strings, plain objects via JSON-like conversion). Treat everything coming out of the sandbox as untrusted data: validate types and sizes before using it.

Evaluating one user formula in the sandbox The host sets a deadline and calls evalCode. The script runs inside QuickJS and calls only injected functions such as getCell, which the host answers with validated values. If the deadline passes, the interrupt handler aborts the script. The result is dumped to a plain value and validated before use. set deadline evalCode(source) script runs in QuickJS isolated heap calls getCell(ref) host validates interrupt if too slow deadline check dump + validate result untrusted output

Step 4 — run in a worker for hard isolation from the UI

The QuickJS interpreter runs synchronously inside the calling thread. Interrupt handlers bound runaway loops, but a script that does heavy legitimate work for its whole time budget still blocks the UI for that long. Running the QuickJS runtime in a dedicated worker keeps the page responsive and adds a second layer: if something goes badly wrong — the Wasm instance traps, memory limits misbehave — terminate the worker and start a fresh one. Exposed host functions then become messages to the main thread, which is slower, so batch what scripts need (for example, pass a snapshot of the cells a formula references).

Step 5 — choose sync or async variants

QuickJS is synchronous; host functions that need to wait (fetching data, reading storage) cannot simply return a promise to a synchronous script. quickjs-emscripten offers an asyncify build that lets host functions be async while scripts call them synchronously, at a cost in size and speed. For most sandboxes, prefer a synchronous API over data the host prepares in advance; reach for async variants only when scripts genuinely need on-demand I/O.

Residual risks

A Wasm-hosted interpreter is a strong boundary, but not a perfect one. Bugs in QuickJS or in the binding layer could, in principle, let a script corrupt the interpreter’s memory — still contained within the Wasm instance, so the page is safe, but the sandbox’s own state and limits may not be. Timing side channels exist: scripts can measure time through the APIs you give them. And your exposed API is the real attack surface: a getCell that accepts arbitrary references might reveal hidden sheets; a log function might be used to flood storage. Review the API like any security boundary, rate-limit side effects, and keep QuickJS and the bindings updated.

When to use a different sandbox

For scripts that need full modern JavaScript performance (large computations), an isolated iframe or a worker with a strict CSP may be more practical, accepting a weaker isolation model. For server-side user code, the same QuickJS approach works in Node, but process-level isolation or a Wasm runtime with fuel and capability control (Wasmtime with a JavaScript engine component) is usually preferred.

Reusing contexts versus fresh contexts

Creating a QuickJS context is cheap but not free, and reusing one across scripts is tempting. Reuse has a cost in isolation: a script can define globals, modify built-ins (Array.prototype.map = ...) or leave data behind that the next script sees. For scripts from different users or with different trust levels, use a fresh context per run, or at least per user. For many runs of the same user’s formulas, a reused context is fine if it is reset between runs — or, better, if the formulas are compiled once into functions that are called repeatedly with new inputs, which avoids re-parsing as well. Freeze the built-ins you expose (Object.freeze on Math and the injected API object, executed inside the context during setup) so scripts cannot tamper with what later scripts rely on.

Testing the sandbox boundary

Treat the sandbox like any security control and test it with hostile inputs. A test suite should confirm that typeof window, typeof globalThis.fetch and typeof importScripts are "undefined" inside the sandbox; that a script allocating in a loop hits the memory limit and fails cleanly; that a tight loop, a deep recursion and a long regular-expression backtrack are each stopped by the interrupt handler or stack limit; that a script cannot reach host objects by walking prototype chains of injected values; and that the host remains responsive throughout. Re-run these tests whenever the QuickJS package, the binding library or the injected API changes.

Expected output

Spreadsheet formulas evaluate in a QuickJS runtime inside a worker with a 16 MB heap and a 50 ms budget per formula; while(true){} returns an “interrupted” error without freezing the UI; scripts can call only getCell and Math built-ins; typeof window inside the sandbox is "undefined"; results are validated before display; and handle leaks are zero in debug builds.

Gotchas

  • Using eval or new Function for user code. Full page authority. Use a real sandbox.
  • No interrupt handler. Infinite loops hang the thread. Always set a deadline.
  • Passing host objects in. They carry authority. Pass plain data and narrow functions.
  • Forgetting to dispose handles. Memory leaks in the QuickJS heap. Dispose or use scopes.
  • Trusting sandbox output. Validate everything that comes out.

Performance note

QuickJS interprets JavaScript, so compute-heavy scripts run far slower than in the browser’s JIT — roughly 20–50 times slower in simple loops — while short formulas evaluate in tens of microseconds, which is ample for spreadsheets and rules.

Time for a short formula versus a heavy loop Microseconds to evaluate a short spreadsheet formula in QuickJS inside Wasm, and milliseconds-scale cost of a million-iteration loop in QuickJS compared with the browser's own engine, shown in microseconds. µs per evaluation short formula in QuickJS 40 µs 1M-iteration loop, page JIT 1,500 µs 1M-iteration loop, QuickJS 52,000 µs

Frequently Asked Questions

Can scripts use import or modules? QuickJS supports ES modules; the host decides which modules are resolvable.

Is a SES/Compartment approach an alternative? Hardened JavaScript isolates code within the page’s engine with different trade-offs; Wasm sandboxes give a separate heap and resource limits.

How large is the QuickJS Wasm build? Around a megabyte uncompressed for the common variants; less compressed.

Can I run TypeScript? Transpile to JavaScript on the host before evaluating.

Should every script get a fresh QuickJS context? For different users or trust levels, yes; for one user’s repeated formulas, reuse a context but freeze built-ins and reset state.

← Back to Cryptography & Untrusted Code