intertwingly

It’s just data

Rails on the BEAM


Same blog from the Twilight Zone post. Same models, controllers, views. Same Turbo Streams broadcasting. Same Action Cable protocol. But check what's serving it:

npx github:ruby2js/juntos --demo blog
cd blog
npx juntos db:prepare
npx juntos up -d sqlite_napi

Open http://localhost:3000. Open a second tab. Create an article. Watch it appear in both. That part you've seen before.

Now imagine a bug in a request handler crashes one of the JavaScript runtimes. On Node.js, that takes down the process — connections drop, state is lost, restart from scratch. On the BEAM, the OTP supervisor restarts just that runtime. The other runtimes keep serving. WebSocket connections stay open. Turbo picks up where it left off.

What Changed

Nothing in the application. The Ruby source is identical. What changed is the target:

Target Command Database Broadcasting
Browser juntos dev -d dexie IndexedDB BroadcastChannel
Node.js juntos up -d sqlite SQLite WebSocket server
BEAM juntos up -d sqlite_napi SQLite or PostgreSQL OTP :pg

The browser uses the BroadcastChannel API for cross-tab sync. Node.js runs a WebSocket server. The BEAM uses Erlang's process groups — distributed by default, no external dependencies.

QuickBEAM

QuickBEAM is a JavaScript runtime for the Erlang VM, built on QuickJS-NG — a lightweight, standards-compliant JavaScript engine. Where Node.js embeds V8 in a C++ process, QuickBEAM embeds QuickJS in an Erlang NIF, giving JavaScript access to the BEAM's concurrency and fault-tolerance primitives.

Each QuickBEAM runtime is a lightweight isolate — Elixir can spin up a pool and dispatch requests round-robin across them, each running on its own OS thread. If one crashes, the OTP supervisor restarts it. The others keep serving. This is the same model Erlang uses for telecom switches — let it crash, recover instantly.

QuickJS isn't V8 — there's no JIT, so raw compute is slower. But for a web application that's mostly I/O (database queries, template rendering, HTTP responses), the difference is negligible. What you gain is a 5MB runtime instead of 45MB, sub-millisecond startup, and the entire OTP ecosystem.

The Architecture

The application runs inside QuickBEAM — a JavaScript runtime embedded in the Erlang VM. Elixir manages everything around it:

Browser (Turbo, Stimulus, Action Cable client)
    ↕ HTTP + WebSocket
Bandit (Elixir HTTP server)
    ↕ Plug router
QuickBEAM (JavaScript runtime pool)
    ↕ Beam.callSync
Elixir (:pg broadcasts, SQLite NIF, OTP supervision)

The JavaScript application handles request routing, controller logic, view rendering, and model operations — the same code that runs on Node.js. Elixir handles what it's best at: concurrency, fault tolerance, and distributed messaging.

Action Cable, Both Sides

The browser runs the real @hotwired/turbo-rails npm package — the same Action Cable client that Rails uses. The <turbo-cable-stream-source> custom element connects to /cable and speaks the Action Cable wire protocol.

On the server, Elixir implements the other side: WebSocket upgrade via Bandit, subscription management via :pg, and broadcast delivery in Action Cable's JSON format. When a model's broadcasts_to callback fires, it crosses from JavaScript to Elixir via Beam.callSync('__broadcast', channel, html), and Elixir fans it out to every subscriber.

Same protocol. Same custom elements. Same Turbo Stream HTML. The client has no idea it's talking to Elixir instead of Rails.

What the BEAM Adds

Things you get for free that would require significant infrastructure on Node.js:

For production, swap SQLite for PostgreSQL — same app, juntos up -d postgrex. Database connections are pooled on the Elixir side via Postgrex, and combined with :pg for broadcasting, you get a fully distributed deployment with no external dependencies beyond Postgres.

A Path to Phoenix

This isn't just another deployment target. It's a migration path.

Your Rails app runs today inside QuickBEAM. The Elixir scaffold is a thin Plug/Bandit layer. But that layer could be Phoenix. At that point:

One at a time. The rest keeps running in QuickBEAM. No big-bang rewrite.

No other migration path offers this. Going from Rails to Phoenix today means starting over. Juntos on BEAM gives you a running app on day one and an incremental path forward.

Try It

Prerequisites: Node.js (18+) and Elixir (1.18+). That's all you need.

npx github:ruby2js/juntos --demo blog
cd blog
npx juntos db:prepare
npx juntos up -d sqlite_napi

Same code. Same patterns. Different runtime. Source code. Documentation.


Juntos is open source: github.com/ruby2js/ruby2js