← History

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.

ErlangElixirGleam

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:

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:

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:

# 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