QuickBEAM
JavaScript runtime for the BEAM — Web APIs backed by OTP, native DOM, and a built-in TypeScript toolchain.
JS runtimes are GenServers. They live in supervision trees, send and receive messages, and call into Erlang/OTP libraries — all without leaving the BEAM.
Installation
def deps do
[{:duskmoon_quickbeam, "~> 9.0"}]
end
Requires Zig 0.15+ (installed automatically by Zigler, or use system Zig).
Quick start
{:ok, rt} = QuickBEAM.start()
{:ok, 3} = QuickBEAM.eval(rt, "1 + 2")
{:ok, "HELLO"} = QuickBEAM.eval(rt, "'hello'.toUpperCase()")
# State persists across calls
QuickBEAM.eval(rt, "function greet(name) { return 'hi ' + name }")
{:ok, "hi world"} = QuickBEAM.call(rt, "greet", ["world"])
QuickBEAM.stop(rt)
BEAM integration
JS can call Elixir functions and access OTP libraries:
{:ok, rt} = QuickBEAM.start(handlers: %{
"db.query" => fn [sql] -> MyRepo.query!(sql).rows end,
"cache.get" => fn [key] -> Cachex.get!(:app, key) end,
})
{:ok, rows} = QuickBEAM.eval(rt, """
const rows = await Beam.call("db.query", "SELECT * FROM users LIMIT 5");
rows.map(r => r.name);
""")
JS can also send messages to any BEAM process:
// Get the runtime's own PID
const self = Beam.self();
// Send to any PID
Beam.send(somePid, {type: "update", data: result});
// Receive BEAM messages
Beam.onMessage((msg) => {
console.log("got:", msg);
});
// Monitor BEAM processes
const ref = Beam.monitor(pid, (reason) => {
console.log("process died:", reason);
});
Beam.demonitor(ref);
Beam API reference
| Category | API | Description |
|---|---|---|
| Bridge | Beam.call(name, ...args) |
Call an Elixir handler (async) |
Beam.callSync(name, ...args) |
Call an Elixir handler (sync) | |
Beam.send(pid, message) |
Send a message to a BEAM process | |
Beam.onMessage(callback) |
Receive BEAM messages | |
| Process | Beam.self() |
PID of the owning GenServer |
Beam.spawn(script) |
Spawn a new JS runtime as a BEAM process | |
Beam.register(name) |
Register the runtime under a name | |
Beam.whereis(name) |
Look up a registered runtime | |
Beam.monitor(pid, callback) |
Monitor a process for exit | |
Beam.demonitor(ref) |
Cancel a monitor | |
Beam.link(pid) / Beam.unlink(pid) |
Bidirectional crash propagation | |
| Distribution | Beam.nodes() |
List connected BEAM nodes |
Beam.rpc(node, runtime, fn, ...args) |
Remote call to another node | |
| Utilities | Beam.sleep(ms) / Beam.sleepSync(ms) |
Async/sync sleep |
Beam.hash(data, range?) |
Non-cryptographic hash (:erlang.phash2) |
|
Beam.escapeHTML(str) |
Escape & < > " ' |
|
Beam.which(bin) |
Find executable on PATH | |
Beam.peek(promise) / Beam.peek.status(promise) |
Read promise result without await | |
Beam.randomUUIDv7() |
Monotonic sortable UUID | |
Beam.deepEquals(a, b) |
Deep structural equality | |
Beam.nanoseconds() |
Monotonic high-res timer | |
Beam.uniqueInteger() |
Monotonically increasing unique integer | |
Beam.makeRef() |
Create a unique BEAM reference | |
Beam.inspect(value) |
Pretty-print any value (including PIDs/refs) | |
| Semver | Beam.semver.satisfies(version, range) |
Check version against Elixir requirement |
Beam.semver.order(a, b) |
Compare two semver strings | |
| Password | Beam.password.hash(password, opts?) |
PBKDF2-SHA256 hash |
Beam.password.verify(password, hash) |
Constant-time verification | |
| Introspection | Beam.version |
QuickBEAM version string |
Beam.systemInfo() |
Schedulers, memory, atoms, OTP release | |
Beam.processInfo() |
Memory, reductions, message queue |
Supervision
Runtimes and context pools are OTP children with crash recovery:
children = [
{QuickBEAM,
name: :renderer,
id: :renderer,
script: "priv/js/app.js",
handlers: %{
"db.query" => fn [sql, params] -> Repo.query!(sql, params).rows end,
}},
{QuickBEAM, name: :worker, id: :worker},
# Context pool for high-concurrency use cases
{QuickBEAM.ContextPool, name: MyApp.JSPool, size: 4},
]
Supervisor.start_link(children, strategy: :one_for_one)
{:ok, html} = QuickBEAM.call(:renderer, "render", [%{page: "home"}])
The :script option loads a JS file at startup. If the runtime crashes,
the supervisor restarts it with a fresh context and re-evaluates the script.
Individual Context processes are typically started dynamically (e.g.
from a LiveView mount) and linked to the connection process.
Context Pool
For high-concurrency scenarios (thousands of connections), use
ContextPool instead of individual runtimes. Many lightweight JS
contexts share a small number of runtime threads:
# Start a pool with N runtime threads (defaults to scheduler count)
{:ok, pool} = QuickBEAM.ContextPool.start_link(name: MyApp.JSPool, size: 4)
# Each context is a GenServer with its own JS global scope
{:ok, ctx} = QuickBEAM.Context.start_link(pool: MyApp.JSPool)
{:ok, 3} = QuickBEAM.Context.eval(ctx, "1 + 2")
{:ok, "HELLO"} = QuickBEAM.Context.eval(ctx, "'hello'.toUpperCase()")
QuickBEAM.Context.stop(ctx)
Contexts support the full API — eval, call, Beam.call/callSync,
DOM, messaging, browser/node APIs, handlers, and supervision:
# In a Phoenix LiveView
def mount(_params, _session, socket) do
{:ok, ctx} = QuickBEAM.Context.start_link(
pool: MyApp.JSPool,
handlers: %{"db.query" => &MyApp.query/1}
)
{:ok, assign(socket, js: ctx)}
end
The context is linked to the LiveView process — it terminates and
cleans up automatically when the connection closes. No explicit
terminate callback needed.
Granular API groups
Contexts can load individual API groups instead of the full browser bundle:
QuickBEAM.Context.start_link(pool: pool, apis: [:beam, :fetch]) # 231 KB
QuickBEAM.Context.start_link(pool: pool, apis: [:beam, :url]) # 108 KB
QuickBEAM.Context.start_link(pool: pool, apis: false) # 58 KB
QuickBEAM.Context.start_link(pool: pool) # 429 KB (all browser APIs)
Available groups: :fetch, :websocket, :worker, :channel,
:eventsource, :url, :crypto, :compression, :buffer, :dom,
:console, :storage, :locks. Dependencies auto-resolve.
Per-context resource limits
{:ok, ctx} = QuickBEAM.Context.start_link(
pool: pool,
memory_limit: 512_000, # per-context allocation limit (bytes)
max_reductions: 100_000 # opcode budget per eval/call
)
# Track per-context memory
{:ok, %{context_malloc_size: 92_000}} = QuickBEAM.Context.memory_usage(ctx)
Exceeding memory_limit triggers OOM. Exceeding max_reductions
interrupts the current eval but keeps the context usable for
subsequent calls.
API surfaces
QuickBEAM can load browser APIs, Node.js APIs, or both:
# Browser APIs only (default)
QuickBEAM.start(apis: [:browser])
# Node.js compatibility
QuickBEAM.start(apis: [:node])
# Both
QuickBEAM.start(apis: [:browser, :node])
# Bare QuickJS engine — no polyfills
QuickBEAM.start(apis: false)
Node.js compatibility
Like Bun, QuickBEAM implements core Node.js APIs. BEAM-specific
extensions live in the Beam namespace.
{:ok, rt} = QuickBEAM.start(apis: [:node])
QuickBEAM.eval(rt, """
const data = fs.readFileSync('/etc/hosts', 'utf8');
const lines = data.split('\\n').length;
lines
""")
# => {:ok, 12}
| Module | Coverage |
|---|---|
process |
env, cwd(), platform, arch, pid, argv, version, nextTick, hrtime, stdout, stderr |
path |
join, resolve, basename, dirname, extname, parse, format, relative, normalize, isAbsolute, sep, delimiter |
fs |
readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync, lstatSync, unlinkSync, renameSync, rmSync, copyFileSync, realpathSync, readFile, writeFile |
os |
platform(), arch(), type(), hostname(), homedir(), tmpdir(), cpus(), totalmem(), freemem(), uptime(), EOL, endianness() |
process.env is a live Proxy — reads and writes go to System.get_env / System.put_env.
Resource limits
{:ok, rt} = QuickBEAM.start(
memory_limit: 10 * 1024 * 1024, # 10 MB heap
max_stack_size: 512 * 1024 # 512 KB call stack
)
Introspection
# List user-defined globals (excludes builtins)
{:ok, ["myVar", "myFunc"]} = QuickBEAM.globals(rt, user_only: true)
# Get any global's value
{:ok, 42} = QuickBEAM.get_global(rt, "myVar")
# Runtime diagnostics
QuickBEAM.info(rt)
# %{handlers: ["db.query"], memory: %{...}, global_count: 87}
Bytecode disassembly
Disassemble QuickJS bytecode into structured Elixir terms — like
:beam_disasm for the BEAM:
{:ok, bc} = QuickBEAM.disasm(rt, "function fib(n) { if (n <= 1) return n; return fib(n-1) + fib(n-2) }")
fib = hd(bc.cpool)
fib.name # "fib"
fib.args # ["n"]
fib.stack_size # 4
fib.opcodes
# [
# {0, :get_arg0, 0},
# {1, :push_1, 1},
# {2, :lte},
# {3, :if_false8, 7},
# {5, :get_arg0, 0},
# {6, :return},
# {7, :get_var, "fib"},
# {12, :get_arg0, 0},
# {13, :push_1, 1},
# {14, :sub},
# {15, :call1, 1},
# ...
# ]
disasm/1 works on precompiled bytecode binaries without a runtime:
{:ok, bytecode} = QuickBEAM.compile(rt, source)
# later, even on a different node:
{:ok, %QuickBEAM.Bytecode{}} = QuickBEAM.disasm(bytecode)
DOM
Every runtime has a live DOM tree backed by lexbor (the C library
behind PHP 8.4's DOM extension and Elixir's fast_html). JS gets a full document global
with spec-compliant prototype chains (instanceof HTMLElement works), node identity
(el.parentNode === el.parentNode), and uppercase tagName for HTML elements:
document.body.innerHTML = '<ul><li class="item">One</li><li class="item">Two</li></ul>';
const items = document.querySelectorAll("li.item");
items[0].textContent; // "One"
items[0] instanceof HTMLElement; // true
Elixir can read the DOM directly — no JS execution, no re-parsing:
{:ok, rt} = QuickBEAM.start()
QuickBEAM.eval(rt, ~s[document.body.innerHTML = '<h1 class="title">Hello</h1>'])
# Returns Floki-compatible {tag, attrs, children} tuples
{:ok, {"h1", [{"class", "title"}], ["Hello"]}} = QuickBEAM.dom_find(rt, "h1")
# Batch queries
{:ok, items} = QuickBEAM.dom_find_all(rt, "li")
# Extract text and attributes
{:ok, "Hello"} = QuickBEAM.dom_text(rt, "h1")
{:ok, "/about"} = QuickBEAM.dom_attr(rt, "a", "href")
# Serialize back to HTML
{:ok, html} = QuickBEAM.dom_html(rt)
Web APIs
Standard browser APIs backed by BEAM primitives, not JS polyfills:
| JS API | BEAM backend |
|---|---|
fetch, Request, Response, Headers |
HTTP.fetch (http_fetch) |
document, querySelector, createElement |
lexbor (native C DOM) |
URL, URLSearchParams |
:uri_string |
EventSource (SSE) |
HTTP.EventSource (http_event_source) |
WebSocket |
HTTP.WebSocket (http_web_socket) |
Worker |
BEAM process per worker |
BroadcastChannel |
:pg (distributed) |
navigator.locks |
GenServer + monitors |
localStorage |
ETS |
crypto.subtle |
:crypto |
crypto.getRandomValues, randomUUID |
Zig std.crypto.random |
ReadableStream, WritableStream, TransformStream |
Pure TS with pipeThrough/pipeTo |
TextEncoder, TextDecoder |
Native Zig (UTF-8) |
TextEncoderStream, TextDecoderStream |
Stream + Zig encoding |
CompressionStream, DecompressionStream |
:zlib |
Buffer |
Base, :unicode |
EventTarget, Event, CustomEvent |
Pure TS |
AbortController, AbortSignal |
Pure TS |
Blob, File |
Pure TS |
DOMException |
Pure TS |
setTimeout, setInterval |
Timer heap in worker thread |
console (log, warn, error, debug, time, group, …) |
Erlang Logger |
atob, btoa |
Native Zig |
performance.now |
Nanosecond precision |
structuredClone |
QuickJS serialization |
queueMicrotask |
JS_EnqueueJob |
HTTP clients use HTTP/1 and OTP TLS by default. Fetch reads buffered or streamed
response bodies as bytes and forwards AbortSignal cancellation to the network request.
Upstream http_fetch decodes gzip and deflate HTTP content encodings; archive bytes
without a Content-Encoding header remain intact.
EventSource parsing, reconnect delays and Last-Event-ID tracking use the upstream
client; transient errors leave JavaScript in CONNECTING, while terminal errors close it.
EventSource and WebSocket use upstream legacy delivery because the native bridge
queues messages without an acknowledgement of JavaScript consumption.
Data conversion
No JSON in the data path. JS values map directly to BEAM terms:
| JS | Elixir |
|---|---|
number (integer) |
integer |
number (float) |
float |
string |
String.t() |
boolean |
boolean |
null |
nil |
undefined |
nil |
Array |
list |
Object |
map (string keys) |
Uint8Array |
binary |
Symbol("name") |
:name (atom) |
Infinity / NaN |
:Infinity / :NaN |
| PID / Ref / Port | Opaque JS object (round-trips) |
TypeScript
Type definitions for the BEAM-specific JS API:
// tsconfig.json
{
"compilerOptions": {
"types": ["./path/to/quickbeam.d.ts"]
}
}
The .d.ts file covers the Beam bridge API, opaque BEAM terms
(BeamPid, BeamRef, BeamPort), and the compression helper.
Standard Web APIs are typed by TypeScript's lib.dom.d.ts.
TypeScript toolchain
QuickBEAM includes a built-in TypeScript toolchain via OXC Rust NIFs — no Node.js or Bun required:
# Evaluate TypeScript directly
{:ok, rt} = QuickBEAM.start()
QuickBEAM.eval_ts(rt, "const x: number = 40 + 2; x")
# => {:ok, 42}
# Transform, minify, bundle — available as QuickBEAM.JS.*
{:ok, js} = QuickBEAM.JS.transform("const x: number = 1", "file.ts")
{:ok, min} = QuickBEAM.JS.minify("const x = 1 + 2;", "file.js")
# Bundle multiple modules into a single IIFE
files = [
{"utils.ts", "export function add(a: number, b: number) { return a + b }"},
{"main.ts", "import { add } from './utils'\nconsole.log(add(1, 2))"}
]
{:ok, bundle} = QuickBEAM.JS.bundle(files)
npm packages
QuickBEAM can use npm_ex to install npm packages without Node.js. Add it when you want Mix-powered npm installs:
def deps do
[
{:duskmoon_quickbeam, "~> 9.0"},
{:duskmoon_npm, "~> 9.0"}
]
end
mix npm.install sanitize-html
The :script option auto-resolves imports. Point it at a TypeScript file
that imports npm packages, and QuickBEAM bundles everything at startup:
# priv/js/app.ts
import sanitize from 'sanitize-html'
Beam.onMessage((html: string) => {
Beam.callSync("done", sanitize(html))
})
{QuickBEAM, name: :sanitizer, script: "priv/js/app.ts", handlers: %{...}}
No build step, no webpack, no esbuild. TypeScript is stripped, imports
are resolved from node_modules/, and everything is bundled into a
single script via OXC — all at runtime startup.
You can also bundle from disk programmatically:
{:ok, js} = QuickBEAM.JS.bundle_file("src/main.ts")
Performance
vs QuickJSEx 0.3.1 (Rust/Rustler, JSON serialization):
| Benchmark | Speedup |
|---|---|
| Function call — small map | 2.5x faster |
| Function call — large data | 4.1x faster |
| Concurrent JS execution | 1.35x faster |
Beam.callSync (JS→BEAM) |
5 μs overhead (unique to QuickBEAM) |
| Startup | ~600 μs (parity) |
Context pool vs individual runtimes at scale:
| Runtime (1:1 thread) | Context (pooled) | |
|---|---|---|
| JS heap per instance | ~530 KB | ~429 KB (full) / ~58 KB (bare) |
| OS thread stack | ~2.5 MB each | shared (4 threads total) |
| OS threads at 10K | 10,000 | 4 (configurable) |
| Total RAM at 10K | ~30 GB | ~4.2 GB (full) / ~570 MB (bare) |
See bench/ for details.
When to use what
| Use case | Module | Why |
|---|---|---|
| One-off eval, scripting | QuickBEAM (Runtime) |
Simple, full isolation |
| SSR request pool | QuickBEAM.Pool |
Checkout/checkin with reset |
| Per-connection state (LiveView) | QuickBEAM.Context |
Lightweight, thousands concurrent |
| Sandboxed user code | QuickBEAM or Context with apis: false |
Memory limits, reduction limits, timeouts |
Development
mix ci
Runs the full project quality gate: formatting, Credo, Dialyzer, Zig lint, TypeScript lint, duplicate-code checks, and tests.
Examples
examples/chat_room/— real-time chat with Phoenix Channels. Each room is a supervised QuickBEAM runtime; JS broadcasts become PubSub pushes to WebSocket clients.examples/ai_agent/— conversational AI agent with streaming, tool use, and pluggable LLM backends. JS orchestrates the conversation loop; Elixir provides the I/O.examples/counter_live/— LiveView counter where each session gets a ~58 KB JS context from a shared pool. The simplest QuickBEAM + Phoenix integration.examples/ssr/— Preact SSR with a pool of runtimes and native DOM. Elixir reads the DOM directly — norenderToString.examples/rule_engine/— user-defined business rules (pricing, validation, transforms) in sandboxed JS runtimes withapis: false, memory limits, timeouts, and hot reload.examples/live_dashboard/— Workers (BEAM processes) compute metrics in parallel and broadcast results via BroadcastChannel (:pg). Crash recovery via OTP supervisor.
License
MIT