Immutability and Per-Process Memory
Why the BEAM gives every process its own heap, copies messages between them, and can collect garbage without ever stopping the world.
Most runtimes treat memory as one big shared pool: every thread allocates into the same heap, the garbage collector occasionally freezes all of them to clean up, and you reach for locks to keep two threads from trampling each other's data. The BEAM rejects that whole arrangement. It is built on two reinforcing ideas - data is immutable and each process owns a private heap - and almost everything good about the platform's latency, fault isolation, and concurrency falls out of them.
This article traces the chain of consequences. Immutable values make it safe to copy data between processes; copying lets each process keep a heap nobody else can touch; private heaps let the garbage collector run on one process at a time; and per-process collection is why a BEAM system can serve millions of requests without the multi-millisecond "stop-the-world" freezes that haunt shared-heap runtimes. We'll see the same model expressed across Erlang, Elixir, and statically typed Gleam, with notes on LFE and Luerl.
Immutability is not a style - it's the foundation
In a BEAM language, once a term is created it never changes. "Updating" a value produces a new value; the original is untouched. This is not a linting convention you can opt out of - it is how the virtual machine represents data.
%% Erlang: rebinding does not mutate. M1 still has its original contents.
M1 = #{a => 1, b => 2},
M2 = M1#{a := 99}, %% a NEW map; M1 is unchanged
M1. %% => #{a => 1, b => 2}
# Elixir: same semantics. `m1` is not touched; `m2` is a new map.
m1 = %{a: 1, b: 2}
m2 = %{m1 | a: 99}
m1 # => %{a: 1, b: 2}
Gleam makes the same guarantee, and its type system makes it loud: there is no assignment operator that mutates a binding in place, only let, which introduces a new name.
import gleam/dict
pub fn demo() {
let m1 = dict.from_list([#("a", 1), #("b", 2)])
let m2 = dict.insert(m1, "a", 99)
// m1 is still {"a": 1, "b": 2}; m2 is a separate value
#(m1, m2)
}
The payoff is that a value can be shared freely without coordination. If nothing can ever write to M1, then any number of readers - on any number of schedulers - can hold it at once with no lock, no memory barrier, and no risk of observing a half-written update. That single property is what makes every later trick possible.
Sharing inside a heap, copying between them
Immutability also makes data structures cheap to "modify." Because the old value is never mutated, an updated structure can share the parts that didn't change with the original - the classic persistent data structure trick. Updating one key in a large Erlang map or Elixir map doesn't clone the whole map; it builds a new spine that points back into the unchanged subtrees. Within a single process heap, M1 and M2 above physically overlap in memory.
That structural sharing works within one heap. The moment data leaves a process, the rules change - and that is the next link in the chain.
Per-process heaps: share-nothing by construction
A BEAM process is not an OS thread. It is a lightweight, VM-scheduled entity that starts at a few hundred words of memory, and - crucially for this article - it has its own heap and its own stack, isolated from every other process. There is no global object heap that processes allocate into. Process A literally cannot hold a pointer into process B's heap.
This is "share-nothing" taken to its logical end. It is the architectural reason the BEAM has no data races: there is no shared mutable state to race over. You do not protect process state with a mutex because no other process can reach it in the first place. The only way to get information from one process to another is to send a message.
%% Each process owns its state entirely. This loop's `State` lives on
%% THIS process's heap and nowhere else; the only way in is a message.
loop(State) ->
receive
{put, K, V} -> loop(maps:put(K, V, State));
{get, K, From} -> From ! {ok, maps:get(K, State, undefined)},
loop(State)
end.
When the process dies, its entire heap is freed in one stroke - no need to trace what's still live, because nothing is. This is what makes "let it crash" affordable: a process is a self-contained unit of memory as well as a unit of failure.
Sending a message means copying
Here is where immutability and private heaps meet. Since a receiver cannot reach into the sender's heap, the data has to get into the receiver's heap somehow. The BEAM's answer is the simplest possible one: it deep-copies the message from the sender's heap into the receiver's heap.
# The map is built on THIS process's heap...
config = %{retries: 3, timeout: 5_000}
child =
spawn(fn ->
# ...and a COPY of it lands on the child's heap when received.
receive do
cfg -> IO.inspect(cfg) # an independent copy; same value, different memory
end
end)
send(child, config) # `config` is deep-copied into the child's heap on send
Because the value is immutable, copying is semantically invisible: the receiver gets a value indistinguishable from the original, and there is no way for either side to observe that two copies exist. Contrast this with a shared-memory language, where passing a mutable object by reference means the two threads now race over it. On the BEAM, the copy is the isolation - the sender can keep mutating its world (by making new values) and the receiver is wholly unaffected.
The trade-off is explicit and honest. Copying costs CPU and memory proportional to the message size, and you reason about it as a real cost - keep messages small, send a pointer-sized PID or a key rather than a megabyte of state when you can. In exchange you get isolation strong enough that one process's heap corruption (or crash) cannot bleed into another's.
%% A worker sends back a small result, not the giant intermediate it built.
%% The big working set stays on the worker's heap and dies with it.
worker(Parent) ->
Big = build_huge_structure(), %% lives only here
Parent ! summarize(Big). %% only the small summary is copied
The exceptions: large binaries and literals
Two important kinds of data are not copied on send, precisely because doing so would be wasteful and because they're still safe to share:
- Large (refc) binaries. Binaries over 64 bytes are not stored on the process heap at all. They live in a separate, VM-wide shared binary heap, reference-counted, and the process heap holds only a small
ProcBinpointer to them. Sending such a binary copies the tiny pointer and bumps the reference count - the multi-kilobyte payload is shared, not duplicated. (Small binaries up to 64 bytes are "heap binaries" and are copied like any other term.) Because binaries are immutable, this sharing is safe; the shared object is freed when its count hits zero. - Literals. Constant terms compiled into a module (a fixed tuple, a string, an atom) live in a per-module literal pool and are referenced, never copied, even across a message send.
These optimizations don't break the mental model - they rely on it. Sharing is only safe because nobody can mutate the shared thing.
# A 1 MB binary is NOT copied on send - both processes reference the same
# off-heap, reference-counted block. Only the small ProcBin handle is copied.
big = :crypto.strong_rand_bytes(1_000_000)
send(some_pid, {:payload, big})
Where the message lands: on_heap vs off_heap
By default (message_queue_data = on_heap) the sender copies the message straight into the receiver's young heap, which means an incoming message becomes part of what the receiver's GC must trace. For processes that take a firehose of messages, you can set the flag to off_heap, so messages are allocated outside the heap and only moved in when actually received - which also lets many senders deliver in parallel without contending for the receiver's heap lock.
%% Good for a process with a high-volume mailbox: keeps the mailbox out of
%% the receiver's GC working set and improves parallel send throughput.
process_flag(message_queue_data, off_heap).
This is a tuning knob, not a change to the semantics: the message is still an immutable copy by the time you pattern-match it.
Garbage collection, one process at a time
Now the whole point pays off. Because each process has a private heap that no one else references, the garbage collector can collect a single process in complete isolation - it never has to coordinate with, or pause, any other process.
The per-process collector is a generational, copying collector. "Copying" means it works by tracing the live data out of one memory region and copying it compactly into another, then discarding the old region wholesale (a Cheney-style two-space scan). Anything not reached is simply abandoned - dead data is never visited, so collection time scales with what's alive, not with what's garbage.
"Generational" (since Erlang/OTP R12B) means the heap is split into a frequently collected young generation for short-lived terms and an infrequently collected old generation for data that has survived a few collections. Most objects die young, so most collections only scan the small young heap - cheap and fast. A fullsweep occasionally scans everything to reclaim the old generation. (Calling erlang:hibernate/3, or setting fullsweep_after, forces full sweeps, which also helps release stale references to shared binaries.)
A few consequences worth naming:
- Immutability simplifies the collector. In a mutating heap, the GC must track pointers from old objects to newly created young ones (the "write barrier" / remembered-set problem). On the BEAM, an older object can never be made to point at a younger one - values are immutable, so references only ever point "backward" in time. The young generation can be collected without scanning the old one.
- Literals are left in place. When collecting, the GC does not copy terms that live in the module literal pool; it recognizes and skips them.
- Death is free collection. If a process finishes or crashes before it ever fills its heap, its memory is reclaimed with no GC pass at all - the heap is just released.
No stop-the-world pauses
Put it together and you get the BEAM's signature latency property. In a shared-heap runtime - a typical JVM or Go service - a major garbage collection has to get a consistent view of the whole heap, which historically meant pausing every application thread (a "stop-the-world" pause) for as long as the collection takes. Even modern concurrent collectors spend real engineering effort shrinking those pauses, because the heap is shared and global.
The BEAM sidesteps the problem rather than optimizing it. Collections are per-process and independent, so:
- One process's GC pauses only that process. Every other process keeps running on its scheduler the entire time.
- Pause times are bounded by the size of one process's live data, which is usually tiny - not by total system memory.
- The collector needs no global synchronization, an advantage that grows with core count: more schedulers can be collecting different processes simultaneously, with zero coordination.
# 100k independent processes, each with its own heap and its own GC clock.
# A collection in any one of them never freezes the other 99,999.
for _ <- 1..100_000 do
spawn(fn ->
big = Enum.to_list(1..10_000) # garbage this process will collect alone
length(big)
end)
end
This is exactly why the BEAM earns its reputation for soft real-time behavior: a chat server, a telecom switch, or a streaming pipeline can keep p99 latency low because no single request can be stalled by a global pause that some other part of the system triggered. The cost of garbage is paid locally, by the process that produced it, while everyone else carries on.
Same model across the family
Erlang, Elixir, LFE, and Gleam are different languages, but they share one runtime - so they share this entire memory model unchanged. The immutability, the private heaps, the copying sends, and the per-process GC are properties of the BEAM, not of any one syntax.
LFE expresses it as S-expressions, but the semantics are identical - an immutable value, a private heap, a copied message:
;; LFE: each spawned process gets its own heap; the tuple is copied on send.
(defun start ()
(let ((parent (self)))
(spawn (lambda ()
(! parent (tuple 'result (* 6 7)))))
(receive
((tuple 'result n) n)))) ;; => 42, received as a copy
Gleam adds a sound static type system on top, but it does not change the memory model - values are still immutable, processes still have private heaps, and messages are still copied. What Gleam adds is that the type of message a process can receive is checked at compile time, via a typed Subject. The runtime cost and the per-process GC are exactly Erlang's:
import gleam/erlang/process.{type Subject}
pub fn run() -> Int {
// A typed mailbox handle. The Int sent through it is copied between heaps
// just like any Erlang message - the type system only constrains its shape.
let inbox: Subject(Int) = process.new_subject()
process.spawn(fn() { process.send(inbox, 6 * 7) })
case process.receive(inbox, within: 1000) {
Ok(n) -> n // 42
Error(_) -> 0
}
}
Gleam's immutability is enforced at compile time rather than merely upheld by convention, but the value that reaches the receiver is the same kind of immutable, independently collected copy it would be in Erlang or Elixir.
Luerl: immutability threaded by hand
Luerl runs Lua 5.x on the BEAM, and Lua is a language with mutable tables - t.x = 1 is supposed to change t in place. That seems to clash with everything above, and the way Luerl resolves it is illuminating.
Luerl keeps the entire Lua state - its tables, globals, and stack - inside a single Erlang term, and threads that immutable state explicitly through every call. Each operation that would "mutate" a Lua table instead returns a new state value; you must keep the returned state and pass it to the next call. Mutation is simulated on top of immutability.
-- In Lua, this looks like in-place mutation of a shared table:
local t = {}
t.x = 1
t.x = 2 -- conceptually overwrites the same table
%% In Luerl (the host side), "mutation" returns a NEW immutable state.
%% You thread State through; there is no hidden in-place update.
{ok, _R1, State1} = luerl:do(<<"t = {}; t.x = 1">>, luerl:init()),
{ok, _Result, State2} = luerl:do(<<"t.x = 2; return t.x">>, State1),
%% State1 still reflects t.x == 1; State2 reflects t.x == 2.
ok.
Because the whole Lua state is just an immutable Erlang term, it gets the BEAM's memory model for free: it lives on the heap of whichever process is running the interpreter, it is copied if you send it to another process, and it is collected per-process like any other term. To get real concurrency you embed Luerl in real BEAM processes - each one running its own interpreter over its own immutable state - and the actor model does the rest. The mutable language gets isolation precisely because the runtime underneath refuses to mutate.
The whole chain, in one breath
Immutability makes it safe to copy data instead of sharing it. Copying lets every process keep a heap that nothing else can reach. Private, share-nothing heaps let the garbage collector run on one process at a time, with no global synchronization and no write barriers - and because an older value can never point at a younger one, even the generational collector stays simple. Per-process collection means GC pauses are local and tiny, so there is no stop-the-world freeze, which is why a BEAM system holds its latency steady under load.
It is one design decision - don't mutate, don't share - followed all the way down to the metal. Erlang, Elixir, LFE, and Gleam inherit it wholesale; Luerl rebuilds Lua's mutability on top of it. Learn to see the heap behind the process, and the BEAM's reliability stops looking like magic and starts looking inevitable.
Further reading
- Erlang Garbage Collector reference: https://www.erlang.org/doc/apps/erts/garbagecollection.html
- "A few notes on message passing" (Erlang/OTP blog): https://www.erlang.org/blog/message-passing/
- Erlang GC details and memory layout (Hamidreza Soleimani): https://hamidreza-s.github.io/erlang%20garbage%20collection%20memory%20layout%20soft%20realtime/2015/08/24/erlang-garbage-collection-details-and-why-it-matters.html
- The BEAM Book: https://blog.stenmans.org/theBeamBook/
- Luerl - Lua on the Erlang VM (Robert Virding): https://www.lua.org/wshop15/Virding.pdf