Erlang Mailboxes: How Asynchronous Message Passing Scales and Where It Fails
Erlang's async message passing relies on per-process mailboxes managed by the BEAM VM. Understand selective receive, unbounded mailbox risks, and practical diagnostics.
28 Jul 2026, 14:37 UTC

The core mechanism: BEAM processes and private mailboxes
Erlang's concurrency model scales because the BEAM virtual machine schedules lightweight processes internally, but that same design hides a risk: each process owns an unbounded private mailbox. If a producer sends faster than a consumer receives, memory grows silently until the node degrades. The practical rule is to treat every mailbox as a bounded resource, even though the runtime does not enforce a bound for you.
Erlang processes are not OS threads. The BEAM handles context switching, so an application can maintain millions of concurrent entities with minimal overhead. Each process has a private mailbox: an ordered queue where incoming messages wait until the process executes a receive block. Processes do not share memory; all communication happens by copying messages between mailboxes.
The send operator ! is asynchronous and non-blocking. It copies a message into the receiver's mailbox and returns immediately. It does not wait for the receiver to read the message, nor does it confirm delivery. If the destination process has already exited, the message is silently discarded.
A worked example: request-reply with selective receive
The following module demonstrates a coordinator that spawns a worker, sends a request, and waits for a specific reply. It uses make_ref/0 to generate a unique reference so the coordinator can distinguish its own reply from unrelated messages already sitting in the mailbox.
-module(mailbox_demo).
-export([start/0, coordinator/0, worker/1]).
start() ->
spawn(fun coordinator/0).
coordinator() ->
Ref = make_ref(),
Worker = spawn(fun() -> worker(Ref) end),
Worker ! {work, Ref, self()},
Result = receive
{result, Ref, Value} ->
{ok, Value}
after 5000 ->
{error, timeout}
end,
io:format("Result: ~p~n", [Result]).
worker(Ref) ->
receive
{work, Ref, From} ->
From ! {result, Ref, 42}
end.
Compile the module in the Erlang shell and call mailbox_demo:start(). The coordinator sends {work, Ref, self()} and immediately proceeds to the receive. Because Ref is unique, the pattern {result, Ref, Value} matches only the intended reply. Any other messages in the mailbox remain untouched. After five seconds without a match, the after clause returns {error, timeout}.
Why selective receive can degrade performance
Selective receive is powerful but has a hidden cost. When a process enters a receive block, the BEAM scans the mailbox from the oldest message forward until it finds a match. If the mailbox contains thousands of unrelated messages and the desired message is near the end, every receive pays the full scan cost. Repeated selective receives on a busy mailbox can degrade overall throughput from O(1) amortized behavior toward O(n) per operation.
This is not a reason to avoid selective receive entirely. It is a reason to keep mailboxes short. If a process routinely handles high message volumes, prefer a single generic receive that dispatches internally, or use gen_server with a well-defined protocol.
The unbounded mailbox problem
Because sends are asynchronous and non-blocking, nothing stops a producer from flooding a consumer. If the consumer cannot process messages as fast as they arrive, the mailbox grows without bound. Eventually the process consumes enough memory to trigger garbage-collection pressure or, in extreme cases, node-level memory exhaustion.
You can observe this directly in the Erlang shell. Spawn a process that never reads its mailbox, flood it with messages, and inspect the queue length:
%% Start the Erlang shell with `erl`. No special permissions required.
Pid = spawn(fun() -> receive never_matches -> ok end end).
[Pid ! spam || _ <- lists:seq(1, 100000)].
process_info(Pid, message_queue_len).
The process_info/2 call should report a message_queue_len equal to the number of messages sent. To see memory impact, compare erlang:memory(total) before and after the flood. The risk is real: in production, an unmonitored mailbox is a memory leak that only manifests under load.
Common mistakes
- Assuming delivery guarantees. The
!operator returns the sent message, not a confirmation. If the receiver crashes between send and receive, the message is lost. Build explicit acknowledgement protocols when correctness depends on delivery. - Using selective receive as a default pattern. Matching a specific reference in a large mailbox is convenient but expensive. Reserve it for low-traffic control paths.
- Simulating shared memory. Erlang processes do not share mutable state. The process dictionary and ETS tables exist for specific use cases, but treating them as global variables contradicts the actor model and reintroduces the race conditions Erlang was designed to avoid.
- Ignoring mailbox growth. A process that falls behind will not raise an error; it will simply consume more memory until the node suffers.
A practical diagnostic habit
During development and load testing, periodically inspect process_info(Pid, message_queue_len) for hot processes. A steadily increasing value signals that consumption logic is too slow or that producers need explicit flow control. Pair this with erlang:memory() to correlate mailbox depth with total process memory. These checks require no external tooling and run directly in the shell or via remote console.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.