Skip to content
BEAM Internals and Performance
step 1/7

Reading — step 1 of 7

Learn

~3 min readgen_statem, Hot Code Reload, Performance

Erlang's runtime — the BEAM — has unusual performance characteristics. Understanding them shapes idiomatic Erlang code.

Process model

  • Each Erlang process has its own ~300-byte initial heap
  • Millions of processes per node are normal (WhatsApp ran 2M TCP connections per node)
  • Process scheduling: preemptive, runtime-controlled (no co-op needed)
  • One scheduler per CPU core; processes migrate as needed

Why processes are cheap

  • No shared memory between processes — each has its own heap
  • Garbage collection is per-process; no global pause
  • Inter-process messages are COPIED (small) or REFERENCED (large binaries)
  • Mailboxes are per-process FIFO queues

Per-process garbage collection

Each process is GC'd independently. Short-lived processes that finish quickly often die before GC kicks in — fastest possible memory reuse.

Long-running processes accumulate. The GC tunes itself based on heap growth; you can hint with process_flag(min_heap_size, N) for known-large workloads.

Binaries: refcounted vs heap

<<"small">>          %% under 64 bytes — heap binary, copied with messages
<<"large data...">>  %% over 64 bytes — refcounted binary, shared via reference

Large binaries (over 64 bytes) live OFF-HEAP and are refcounted. Sending one to another process sends a reference, not a copy. Big win for streaming binary protocols.

Leak hazard: if any process holds a reference to a big binary, the whole binary stays alive. Patterns like binary:copy/1 exist to extract a small piece without retaining the big original.

ETS for shared state

Processes don't share memory — but ETS tables provide concurrent in-memory key-value storage. Already covered; the key performance points:

  • Reads are nearly free (concurrent, no locking)
  • Writes serialize on a per-table lock by default; {write_concurrency, true} reduces contention
  • Data is COPIED in/out — terms duplicate on every read
  • Key lookups: O(log n) for ordered_set, O(1) average for set/bag

Performance gotchas

Strings as lists"hello" is [104, 101, 108, 108, 111]. List operations on text are O(n) per character. For string manipulation, use binaries.

++ is O(n) on the LEFT operand:

L ++ X     %% iterates L; if L is large and X is small, this is slow

Build lists from the right (using [H | T]) and reverse at the end:

build(List, Acc) -> [Item | Acc].   %% O(1) prepend
...
lists:reverse(Acc)

Map vs list of pairs — for many lookups, maps are O(log n) while proplists are O(n). Use maps.

Profiling

  • fprof — function-level profiling
  • eprof — call counts and time per function
  • cprof — call counts only (low overhead)
  • recon — production-friendly inspection (very useful)
  • observer — graphical introspection (observer:start())
fprof:apply(my_module, my_function, [Args]).
fprof:profile().
fprof:analyse().

For production, prefer recon — designed for live systems.

Memory introspection

erlang:memory().              %% process heap, ets, atoms, binary, etc.
erlang:process_info(Pid).     %% per-process details
erlang:system_info(process_count).

Common mistakes

  • Spawning many short-lived processes for tiny work — usually fine, but create cost adds up. Pool when work is sub-millisecond per item.
  • Atom explosion — list_to_atom on user input. Atoms aren't GC'd. Bounded atom table.
  • Big binaries pinned by small references — common leak. Use binary:copy/1 to extract small slices without retaining the big source.
  • Unbounded mailboxes — selective receive over a mailbox of millions of messages is O(n²). Apply backpressure.
  • String concatenation in tight loops — use iolists (deeply nested lists of binaries/strings); flatten only at the I/O boundary.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…