Building Fault‑Tolerant Concurrency with Erlang’s Lightweight Processes
Erlang’s lightweight processes enable millions of concurrent tasks with minimal memory. Learn how to build a fault‑tolerant counter server, watch it recover from crashes, and understand key trade‑offs like mailbox size, data copying, and supervision loops.
06 Sept 2025, 15:45 UTC

Why Erlang’s Process Model Matters
Erlang’s core strength is its lightweight process model. Unlike operating‑system threads, Erlang processes are managed by the BEAM virtual machine and each owns its own heap and stack. This design allows a single BEAM instance to run millions of processes with minimal memory overhead, while still providing isolation, asynchronous communication, and fault tolerance through supervision trees.
Key Features at a Glance
- Asynchronous message passing – no shared state, no locks.
- Preemptive scheduling – the VM ensures no process can starve the others.
- Supervision trees & "Let it Crash" – simple restart strategies for resilient systems.
- gen_server behavior – a reusable server skeleton that handles state, synchronous calls, and asynchronous casts.
Concrete Example: A Counter Server
Below is a minimal counter_server implemented as a gen_server. It keeps an integer counter, supports incrementing, reading, and resetting the value. The example demonstrates state persistence and automatic recovery when the server crashes.
%% counter_server.erl
-module(counter_server).
-behaviour(gen_server).
%% API
-export([start_link/0, inc/0, get/0, reset/0]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, 0, []).
inc() -> gen_server:cast(?MODULE, inc).
get() -> gen_server:call(?MODULE, get).
reset() -> gen_server:call(?MODULE, reset).
init(Initial) -> {ok, Initial}.
handle_call(get, _From, State) -> {reply, State, State};
handle_call(reset, _From, _State) -> {reply, ok, 0}.
handle_cast(inc, State) -> {noreply, State + 1}.
handle_info(_Info, State) -> {noreply, State}.
terminate(_Reason, _State) -> ok.
code_change(_OldVsn, State, _Extra) -> {ok, State}.
To run the server, start an Erlang shell and load the module:
1> c(counter_server).
2> counter_server:start_link().
3> counter_server:inc().
4> counter_server:get().
5> counter_server:reset().
Now, intentionally crash the process to see the supervisor in action:
%% In a separate module (e.g., supervisor_demo.erl)
-module(supervisor_demo).
-behaviour(supervisor).
-export([start_link/0, init/1]).
start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
CounterSpec = #{id => counter_server,
start => {counter_server, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [counter_server]},
{ok, {{one_for_one, 5, 10}, [CounterSpec]}}.
After starting the supervisor, kill the counter process:
1> supervisor_demo:start_link().
2> Pid = whereis(counter_server).
3> exit(Pid, kill).
The supervisor will detect the crash and restart the server automatically. Use observer or supervisor:which_children/1 to confirm the new PID.
Trade‑Offs & Practical Limits
Memory Footprint of Mailboxes
Each Erlang process has its own mailbox. While the process header is tiny (~64 bytes), a mailbox can grow unbounded if a process consumes messages slower than they arrive. This can lead to memory exhaustion. Design producers to respect consumer rates or use bounded queues (e.g., gen_server:reply/2 with a timeout).
Copying Data Between Processes
Messages are copied from sender to receiver. For large binaries, this can be expensive. Use binary:refc to share references instead of copying, or send the binary as a reference via erlang:make_ref() and let the receiver fetch it from a shared storage.
Supervision Loop Risks
When a child repeatedly crashes, the supervisor may restart it endlessly. The default max_restarts and max_seconds limits help, but careful design is needed to avoid “crash loops” that can overwhelm the system. Consider adding a max_restarts of 3 per 5 seconds for critical services.
How to Measure Process Overhead
To verify the lightweight nature, you can spawn a large number of processes and compare memory usage to OS threads:
1> Fun = fun() -> timer:sleep(1000000) end.
2> [spawn(Fun) || _ <- lists:seq(1, 100000)].
On a typical machine, 100,000 Erlang processes consume a few megabytes, while the same number of OS threads would exceed the address space. Use erlang:process_info/2 to inspect a process’s heap and stack size for deeper analysis.
Actionable Takeaway
When building highly concurrent, fault‑tolerant systems, leverage Erlang’s lightweight processes and supervision trees. Start with gen_server for common server patterns, design bounded mailboxes, and set sensible restart limits. Measure memory usage for your workload and adjust the max_restarts policy to avoid runaway restarts. With these practices, you can scale to millions of concurrent tasks while keeping the system resilient and responsive.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.