← History

One VM, Many Languages: the BEAM story

How a virtual machine built for telephone switches became a shared home for five very different languages - and what each one inherits the moment it targets it.

ErlangElixirGleamLFELuerl

Most language runtimes are built for one language. The JVM was built for Java; CPython runs Python; V8 runs JavaScript. The BEAM is unusual: it was built to run Erlang, a language designed to keep telephone exchanges answering calls for decades without rebooting - and yet today it cheerfully hosts a Ruby-flavoured language, a sound statically typed one, a Lisp, and even an interpreter for Lua. They share one runtime, one concurrency model, and one set of hard-won reliability guarantees.

This article is about that runtime: what the BEAM actually is, why so many languages decided to target it, and what every one of them inherits the moment they do.

What "the BEAM" actually means

BEAM stands for Bogdan/Björn's Erlang Abstract Machine - named after Bogumil Hausman and Björn Gustavsson, who built it. It is the virtual machine inside Erlang/OTP that executes compiled bytecode. Erlang did not start here: the first production implementation ran on JAM, a stack-based machine, and BEAM (a faster, register-based design) replaced it in the 1990s. Crucially, "the BEAM" is now used loosely to mean the whole runtime system - the bytecode interpreter, the scheduler, the per-process memory management, the I/O system, and the distribution layer - not just the instruction set.

It is worth separating three things that get casually bundled together:

Those three pieces, working together, are the reason the BEAM behaves the way it does. Let's take each in turn.

Bytecode and a common compile target

Source code in a BEAM language never runs directly. It is compiled down toward BEAM bytecode and loaded into the running system as a module. What's interesting is that several languages share an intermediate target on the way down: Core Erlang, a small, explicitly functional language that the Erlang compiler uses internally.

Erlang itself is the obvious case, but it is far from alone:

%% erlang - a module compiled to a .beam file
-module(math_utils).
-export([double/1, sum/1]).

double(X) -> X * 2.

sum(List) -> lists:foldl(fun(X, Acc) -> X + Acc end, 0, List).

LFE (Lisp Flavoured Erlang) compiles to Core Erlang, which is precisely why a Lisp can sit as a first-class citizen next to Erlang with zero interop penalty - the same code generator produces the final bytecode:

;; lfe - S-expressions in, Core Erlang (then BEAM bytecode) out
(defmodule math-utils
  (export (double 1) (sum 1)))

(defun double (x) (* x 2))

(defun sum (lst)
  (lists:foldl (lambda (x acc) (+ x acc)) 0 lst))

Elixir takes a slightly different path - it compiles its own AST straight to BEAM bytecode rather than via Erlang source - but the destination is identical, and the resulting modules are indistinguishable from Erlang's at load time:

# elixir - compiles to BEAM bytecode; calls Erlang's stdlib directly
defmodule MathUtils do
  def double(x), do: x * 2

  def sum(list), do: Enum.reduce(list, 0, fn x, acc -> x + acc end)
  # ...or call straight into Erlang: :lists.foldl(&(&1 + &2), 0, list)
end

Gleam - the one statically typed language in this family - does its type checking up front, then emits Erlang source code, which the standard Erlang compiler turns into bytecode. (Gleam can also emit JavaScript, but on the BEAM it goes through Erlang.) The types are a compile-time story; at runtime, Gleam values are ordinary BEAM terms:

// gleam - fully type-checked, then lowered to Erlang and compiled to BEAM
import gleam/list

pub fn double(x: Int) -> Int {
  x * 2
}

pub fn sum(numbers: List(Int)) -> Int {
  list.fold(numbers, 0, fn(acc, x) { acc + x })
}

Because they all converge on the same bytecode and the same in-memory term representation, modules written in these languages can call each other directly. An Elixir program calling :lists.foldl/3, or a Gleam program importing an Erlang module via an @external declaration, is not "FFI" in the painful C-bindings sense - it is one BEAM module calling another.

Luerl is the deliberate exception. It does not compile Lua to BEAM bytecode. Instead, Luerl is a complete Lua interpreter written in pure Erlang: it parses Lua, compiles it to its own internal representation, and evaluates it inside an Erlang library. The Lua you write runs on top of the BEAM rather than being lowered into it:

-- lua (run by Luerl) - evaluated by an Erlang interpreter, not compiled to BEAM
local function double(x) return x * 2 end

local function sum(t)
  local acc = 0
  for _, x in ipairs(t) do acc = acc + x end
  return acc
end

return sum({1, 2, 3}), double(21)

The host Erlang or Elixir program drives that evaluation, threading the entire Lua state through each step as an immutable Erlang data structure. That design is what makes Luerl naturally sandboxable: untrusted Lua never escapes the safety envelope of the VM that's interpreting it. It's a different relationship to the BEAM than the other four - embedding rather than compiling - but it's still the BEAM doing the work.

The scheduler: preemptive, fair, and counted in reductions

Here is the single most important thing the BEAM gives every language for free: lightweight processes that are preemptively scheduled.

A BEAM "process" is not an OS process or thread. It is an exceptionally cheap unit of execution managed entirely by the runtime - small enough that spawning hundreds of thousands or millions of them is routine. Each one has its own stack and heap, shares nothing with its neighbours, and communicates only by sending asynchronous messages.

%% erlang - spawning a process is a single, cheap operation
Pid = spawn(fun() ->
    receive
        {hello, From} -> From ! {hi, self()}
    end
end),
Pid ! {hello, self()},
receive {hi, P} -> io:format("got reply from ~p~n", [P]) end.
# elixir - same machinery (spawn / send / receive), friendlier syntax
pid = spawn(fn ->
  receive do
    {:hello, from} -> send(from, {:hi, self()})
  end
end)

send(pid, {:hello, self()})
receive do
  {:hi, p} -> IO.puts("got reply from #{inspect(p)}")
end

What makes this trustworthy is how those processes are scheduled. By default the runtime starts one scheduler thread per CPU core, and each scheduler runs many processes by interleaving them. The key trick is that scheduling is preemptive and based on reduction counting: roughly, every function call costs one reduction, and a process is allowed a fixed budget (historically around 4,000 reductions) before the scheduler suspends it and lets another run.

This has a profound consequence. On most runtimes, a single tight loop or a long computation in one task can starve everything else - cooperative schedulers depend on code politely yielding. On the BEAM, the runtime takes the CPU back whether your code wants to yield or not. One misbehaving process cannot monopolize a core, so latency stays predictable even under load. That property - soft real-time behaviour - is exactly what telephony needed, and every language on the VM inherits it without writing a line of scheduling code.

Operations that could block, like file and socket I/O, are handled off the scheduler threads so they don't stall the processes that aren't waiting on them. The net effect from a language designer's point of view: you express concurrency as plain processes, and the runtime makes it fair.

Per-process garbage collection: no stop-the-world

The second pillar is memory. Because each process has its own private heap, each process is garbage collected independently.

This is the opposite of a global, stop-the-world collector. When one process needs to reclaim memory, only that process pauses - typically for microseconds, because its heap is small - while every other process keeps running. There is no single moment where the whole system freezes to collect garbage. For a system with a million processes, that's the difference between a runtime that's viable for low-latency, always-on workloads and one that isn't.

It works hand-in-hand with two other design choices:

That last point is the bridge to reliability. Isolated heaps mean a process can fail completely and be thrown away without leaving the rest of the system in a half-broken state - which is the foundation of the BEAM's most famous idea.

What everyone inherits

Adopt the BEAM as your backend and a remarkable amount comes along for the ride. None of the five languages had to design these from scratch:

A supervision tree, expressed in Erlang's OTP, looks like this - and the same machinery is available, with native syntax, to Elixir, Gleam, and LFE:

%% erlang - an OTP supervisor restarts a crashed worker automatically
init([]) ->
    SupFlags = #{strategy => one_for_one, intensity => 5, period => 10},
    Child = #{id => worker,
              start => {my_worker, start_link, []},
              restart => permanent},
    {ok, {SupFlags, [Child]}}.
# elixir - the same OTP supervisor, idiomatic Elixir
children = [
  {MyWorker, []}
]

Supervisor.start_link(children, strategy: :one_for_one)

The fact that the LFE language file describes inheriting "lightweight processes, message-passing concurrency, supervision trees, hot code loading, and the let-it-crash philosophy" for free - and that Gleam ships a type-safe implementation of the same OTP actor model - is not coincidence. It's the whole point. The hard parts were solved once, at the level of the virtual machine, and the languages get to focus on syntax, type systems, and ergonomics.

Five answers to "why this VM?"

The languages target the BEAM for overlapping but distinct reasons, which is what makes the family interesting:

Four of them lower their code onto the VM; one runs on top of it as an interpreter. All five, in different ways, are answers to the same realization: building a great concurrent, fault-tolerant runtime is enormously hard, and once someone has built one as good as the BEAM, the smart move is to share it.

Takeaway

The BEAM is a runtime that treats processes - cheap, isolated, independently collected, preemptively scheduled - as the fundamental unit of a program. That single design decision, made for telephone switches in the 1980s, turned out to be exactly what a whole generation of concurrent, reliable systems needed. The languages differ at the surface: dynamic or static, parentheses or pipes, compiled or interpreted. Underneath, they all speak the same machine - and inherit a reliability story they would have spent years building alone.

One VM. Many languages. One very good idea, shared.