Understanding Memory in the BEAM VM
Memory management in Elixir is fundamentally different from what you might be used to in languages like C, C++, or even JavaScript. Elixir runs on the BEAM virtual machine (originally designed for Erlang), which implements a unique per-process memory model with independent garbage collection. There is no single global heap that requires a stop-the-world collector to pause your entire application. Instead, each BEAM process owns its own private heap, and garbage collection happens on a per-process basis, typically when a process needs more memory.
This architecture enables the remarkable concurrency and fault-tolerance properties that Elixir is famous for. When one process performs garbage collection, other processes continue running unaffected. When a process terminates, its entire memory space is immediately reclaimed without any collector involvement. Understanding how this memory model works under the hood is essential for building performant, scalable Elixir applications that don't suffer from unexpected memory pressure or subtle performance degradation.
Why Memory Management Matters in Elixir
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Even though the BEAM VM handles most memory concerns automatically, a lack of awareness about how memory works can lead to serious production issues. Memory is not infinite, and poor process design can cause individual processes to grow their heaps to hundreds of megabytes, triggering expensive garbage collection cycles. In extreme cases, you can exhaust system memory entirely, causing the VM to crash. More commonly, you'll experience latency spikes when large processes perform major garbage collections, degrading the responsiveness your users expect.
Understanding memory management helps you:
- Diagnose memory leaks — processes that retain references to data they no longer need
- Optimize throughput — by keeping process heaps small and GC cycles fast
- Tune the VM — adjusting memory allocation settings for your specific workload
- Design better architectures — leveraging process isolation to bound memory usage
- Avoid common pitfalls — like large binary handling, ETS table growth, and message queue buildup
The BEAM Memory Model: Processes and Heaps
When you spawn a new process in Elixir with spawn/1 or Task.async/1, the BEAM allocates a small initial heap for that process (typically around 233 words by default). This heap stores all the process's mutable state: its stack frames, local variables, and any data structures it creates. Critically, data is not shared between processes. When you send a message from one process to another, the data is copied into the receiving process's heap (with an important exception for large binaries, which we'll explore shortly).
Here's a simple illustration of process heap isolation:
# Each process has its own independent heap
defmodule MemoryDemo do
def run do
parent = self()
spawn(fn ->
# This process's heap contains its own copy of the list
data = Enum.to_list(1..100_000)
send(parent, {:data_length, length(data)})
# When this process exits, its entire heap is reclaimed immediately
end)
# The parent process never held the large list in its heap
receive do
{:data_length, len} -> IO.puts("Received length: #{len}")
end
end
end
This isolation is the foundation of Elixir's fault tolerance. A process that runs out of memory will be terminated by the VM, but its crash won't corrupt another process's heap. Your supervisor tree can then restart that process from a clean state.
Garbage Collection: Generational and Per-Process
The BEAM uses a generational garbage collector for each process. Newly allocated data goes into the young heap (also called the nursery). When the young heap fills up, a minor collection occurs: live data is moved to the old heap, and dead data is discarded. After several minor collections, if the old heap grows too large, a major collection sweeps the entire old heap as well.
The key insight is that most data in Elixir processes is short-lived. A function allocates some intermediate lists or maps, does its work, and returns a result. Those intermediate allocations die quickly and are cleaned up by cheap minor collections. Long-lived data (like the state of a GenServer) eventually ends up in the old heap, where it sits until a major collection becomes necessary.
# Demonstrating allocation patterns
defmodule GCDemo do
def short_lived do
# This function creates a temporary list that dies when it returns
Enum.sum(1..10_000) # The intermediate list is garbage after this call
end
def long_lived_state do
# A GenServer keeps state in its heap permanently
state = %{users: %{}, config: %{}, metrics: []}
# This state persists across many GC cycles in the old heap
{:ok, state}
end
end
Large Binaries and the Shared Heap
There is one crucial exception to the "everything is copied" rule: large binaries. When you send a binary larger than approximately 64 bytes between processes, the BEAM stores the binary data in a shared reference-counted heap, and only a small reference to that binary is copied into the receiving process's heap. This optimization is called heap binaries vs. off-heap binaries (or refc binaries).
This is tremendously efficient for passing large payloads between processes, but it introduces a subtle memory consideration. Because the binary is reference-counted, if you retain a small slice of a large binary, the entire original binary stays alive. The garbage collector cannot free it until all references are gone.
# The binary slicing memory pitfall
defmodule BinaryTrap do
def dangerous do
# Read a 10MB file into a binary
huge_binary = File.read!("large_file.dat")
# Extract just 10 bytes — but the entire 10MB binary stays alive!
tiny_slice = binary_part(huge_binary, 0, 10)
# huge_binary may go out of scope, but tiny_slice references the
# underlying shared binary, preventing the VM from freeing it
{huge_binary, tiny_slice}
end
def safe do
# Instead, explicitly copy just what you need
huge_binary = File.read!("large_file.dat")
tiny_slice = :binary.copy(binary_part(huge_binary, 0, 10))
# Now the large binary can be garbage collected
{huge_binary, tiny_slice}
end
end
Use :binary.copy/1 when you need to keep a small slice and want to release the rest. In practice, this issue most commonly appears when working with network requests or file I/O where you deserialize a small part of a large payload.
ETS Tables and Memory Ownership
ETS (Erlang Term Storage) tables are another memory area worth understanding. When you create an ETS table, its data lives in a memory space that is tied to the owning process. If the owning process dies, the entire ETS table is automatically deleted and its memory reclaimed. This is a powerful pattern for managing shared state, but it also means the owning process's heap can appear deceptively small while it actually anchors a large ETS table elsewhere in VM memory.
defmodule EtsMemoryDemo do
def create_large_table do
# The process that calls this becomes the table owner
table = :ets.new(:my_cache, [:set, :public, :named_table])
# Insert millions of entries — memory is allocated in ETS space,
# not directly in the process heap
for i <- 1..1_000_000 do
:ets.insert(:my_cache, {i, %{data: "value_#{i}"}})
end
# This process's heap remains small, but it owns a huge ETS table
IO.puts("Table created. Process memory is misleadingly low.")
:ok
end
end
Always consider ETS memory when profiling. The :erlang.memory() function can reveal ETS-specific memory usage, helping you track down where memory actually lives.
Monitoring Memory with Erlang Tools
The BEAM provides excellent introspection capabilities. You can inspect memory usage at both the VM level and per-process level. Here are the essential tools:
# Global VM memory breakdown
:erlang.memory()
# Returns a map with keys like :total, :processes, :ets, :binary, :atom, :code
# Per-process memory details
process_info = Process.info(self(), [:memory, :heap_size, :stack_size, :total_heap_size])
# => [memory: 4567, heap_size: 233, stack_size: 12, total_heap_size: 4567]
# Find the process with the largest heap
Process.list()
|> Enum.map(fn pid -> {pid, Process.info(pid, [:memory])} end)
|> Enum.filter(fn {_, info} -> info != :undefined end)
|> Enum.sort_by(fn {_, [memory: mem]} -> mem end, :desc)
|> Enum.take(5)
# Detailed garbage collection statistics
:erlang.statistics(:garbage_collection)
# Returns information about number of GCs and amount of memory reclaimed
For production systems, integrating these measurements into your telemetry pipeline is invaluable. Libraries like :telemetry and tools like Observer (the graphical VM inspector) can visualize memory trends over time.
# Using the built-in Observer GUI (great for development)
:observer.start()
# Or collecting telemetry programmatically
defmodule MemoryTelemetry do
def sample do
memory = :erlang.memory()
# Report to your metrics system
Telemetry.execute([:memory, :total], memory[:total])
Telemetry.execute([:memory, :processes], memory[:processes])
Telemetry.execute([:memory, :binary], memory[:binary])
end
end
Message Queues and Memory Pressure
Each BEAM process has a message queue (mailbox) that holds incoming messages until the process can process them. If a process falls behind — perhaps because it's doing CPU-intensive work or waiting on an external service — its mailbox can grow unboundedly, consuming significant memory. More importantly, a process with a huge mailbox will experience extremely slow garbage collection because the collector must scan the entire queue for live references.
# Detecting processes with large mailboxes
defmodule MailboxMonitor do
def check do
for pid <- Process.list() do
with info when is_list(info) <- Process.info(pid, [:message_queue_len, :memory]) do
queue_len = info[:message_queue_len]
mem = info[:memory]
if queue_len > 1000 do
IO.puts("Warning: Process #{inspect(pid)} has #{queue_len} queued messages, #{mem} bytes")
end
end
end
:ok
end
end
# Example: a slow GenServer that can't keep up
defmodule SlowHandler do
use GenServer
def handle_call(:work, _from, state) do
# Simulating slow processing — messages pile up in the mailbox
:timer.sleep(5000)
{:reply, :done, state}
end
end
To protect against this, design your processes to process messages quickly, use selective receive patterns carefully (they scan the entire queue), and consider using Process.flag(:message_queue_data, :off_heap) for processes that routinely handle large messages — this stores incoming messages in a separate memory area to keep the process heap small.
Tuning VM Memory Settings
The BEAM exposes several environment variables and flags that control memory allocation behavior. While the defaults work well for many applications, understanding these knobs helps you optimize for specific workloads:
- ERL_MAX_PORTS — maximum number of simultaneously open ports (file handles, sockets)
- ERL_FULLSWEEP_AFTER — number of minor GCs before forcing a full major collection (default varies)
- ERL_CRASH_DUMP_SECONDS — time allowed for crash dump generation on VM crash
- +MMscs — space calculator strategy for memory allocation
# Example: starting the VM with custom memory tuning
# elixir --erl "+MMscs 1" --erl "+MIscs 4096" -S mix run --no-halt
# Within a release, you can set these in vm.args
# vm.args file:
# +MMscs 1
# +MIscs 4096
# -env ERL_FULLSWEEP_AFTER 0
The +MMscs flag controls how the VM calculates memory space needs. Setting it to 1 uses a strategy that may reduce memory usage at the cost of more frequent GCs. Setting ERL_FULLSWEEP_AFTER 0 forces a full major collection after every minor collection, which can reclaim memory more aggressively but increases CPU usage. Profile before tuning — these are advanced knobs that can hurt performance if misapplied.
Best Practices for Memory-Efficient Elixir
1. Keep Process Heaps Small and Short-Lived
The ideal Elixir process does a bounded amount of work and exits, releasing all its memory. Long-running processes (like GenServers) should keep their state compact. If a GenServer needs to accumulate data, consider offloading it to ETS or a dedicated storage process that can be rotated.
# Good: small, focused GenServer state
defmodule UserSession do
use GenServer
# State is just a small map — heap stays tiny
def handle_cast({:login, user_id}, state) do
{:noreply, Map.put(state, :current_user, user_id)}
end
end
# Better: offload bulk data to ETS
defmodule SessionCache do
def init do
table = :ets.new(:sessions, [:set, :public])
{:ok, %{table: table, counter: 0}}
end
def handle_cast({:store, key, data}, state) do
:ets.insert(state.table, {key, data})
{:noreply, state}
end
end
2. Be Mindful of Binary References
When parsing large payloads, explicitly copy out small pieces. Use :binary.copy/1 or pattern-match to extract and duplicate. This is especially important in web applications handling large request bodies.
# Parsing JSON from a large request body safely
def parse_user_data(body) do
case Jason.decode(body) do
{:ok, %{"name" => name} = _full} ->
# The full decoded map references the original binary
# If you only need the name, consider:
safe_name = :binary.copy(name)
{:ok, safe_name}
error -> error
end
end
3. Use Processes as Memory Boundaries
Structure your application so that memory-intensive operations happen in isolated, short-lived processes. This leverages the natural reclamation when the process exits and prevents a single leaky process from dragging down the entire system.
# Spawn a temporary process for a memory-intensive task
def process_large_dataset(data) do
Task.async(fn ->
# This process allocates heavily, but it will exit soon
result = data
|> Enum.map(&expensive_transform/1)
|> Enum.filter(&complex_filter/1)
|> Enum.sum()
# Return only the final result, not the intermediate data
result
end)
|> Task.await()
# After await, the task process exits and all its memory is freed
end
4. Monitor and Alert on Memory Growth
Set up telemetry handlers that track memory over time. A slow, steady increase in process memory or binary memory often indicates a leak. Tools like :erlang.memory() sampled every minute can reveal trends before they become critical.
# Periodic memory sampling in your supervision tree
defmodule MemorySampler do
use GenServer
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def init(state) do
schedule_sample()
{:ok, state}
end
def handle_info(:sample, state) do
mem = :erlang.memory()
# Log or report to your metrics backend
Logger.info("Memory snapshot: total=#{mem[:total]} process=#{mem[:processes]} ets=#{mem[:ets]}")
schedule_sample()
{:noreply, state}
end
defp schedule_sample do
Process.send_after(self(), :sample, :timer.minutes(1))
end
end
5. Avoid Atom Exhaustion
Atoms are stored in a fixed-size global atom table (default max around 1 million, but configurable). Each unique atom consumes memory that is never garbage collected. Creating atoms dynamically from user input (e.g., via String.to_atom/1) is dangerous. Use String.to_existing_atom/1 or prefer string keys in maps for dynamic data.
# Dangerous: creates unlimited atoms from user input
def bad_parse_param(param) do
String.to_atom(param) # Attacker could exhaust atom table
end
# Safe: use existing atoms or string keys
def good_parse_param(param) do
# Option 1: use existing atoms only
String.to_existing_atom(param)
# Option 2: use string keys in maps
%{param => true}
end
Common Memory Pitfalls and Their Solutions
Pitfall 1: The "Growing State" GenServer
A GenServer that appends to a list or inserts into a map indefinitely will eventually consume all available memory. The fix: use bounded data structures, implement expiration, or offload to disk/ETS with cleanup.
# Problem: unbounded growth
def handle_info({:event, data}, state) do
{:noreply, [data | state]} # List grows forever
end
# Solution: bounded ring buffer
def handle_info({:event, data}, state) do
bounded = Enum.take([data | state], 1000) # Keep only last 1000 events
{:noreply, bounded}
end
Pitfall 2: Accidental Closure Retention
When you capture variables in anonymous functions (closures), those variables are pinned into the process heap for as long as the function lives. If you store closures in long-lived state, they may hold references to data you thought was garbage collected.
# A closure holding a reference to huge data
def create_handler(huge_data) do
# This closure keeps huge_data alive even if the caller discards it
fn key -> Map.get(huge_data, key) end
end
# Better: extract only what's needed
def create_handler_better(huge_data) do
needed = Map.take(huge_data, [:key1, :key2])
fn key -> Map.get(needed, key) end
end
Pitfall 3: Protocol Consolidation and Memory Fragmentation
Protocol implementations are consolidated at compile time, but if you have many protocol implementations across many modules, the code memory footprint grows. This is rarely a problem, but in very large projects, consider whether protocol usage is justified versus plain function dispatch.
Conclusion
Memory management in Elixir is a fascinating blend of automatic garbage collection and deliberate architectural control. The BEAM's per-process heap model gives you incredible isolation and fault tolerance, but it asks you to think differently about how data flows through your system. By understanding process heaps, binary sharing, ETS ownership, and mailbox dynamics, you can build applications that not only scale horizontally across millions of processes but also maintain predictable, efficient memory profiles.
The key takeaways are: keep process state small and bounded, be cautious with binary slices, leverage process lifecycle for automatic cleanup, monitor your VM memory regularly, and never create atoms from untrusted input. With these principles in mind, you'll avoid the most common memory pitfalls and deliver robust, production-hardened Elixir systems that make the most of the BEAM's unique memory architecture.