When you first create a process in Elixir you usually think of it as a tiny, isolated unit that can receive a few messages and then terminate. Real‑world systems, however, often need long‑running processes that keep some internal data, react to many different kinds of requests, and possibly communicate with other processes. Those are generic server processes – the backbone of most concurrent applications built on the BEAM.
This article walks you through the evolution from a bare‑bones process that you build yourself to the full‑featured GenServer behaviour that ships with Elixir. Along the way we will:
- Identify the common responsibilities of a server process.
- Show how to factor those responsibilities into reusable, generic code.
- Extend the generic server to handle both synchronous (
call) and asynchronous (cast) messages. - Replace the hand‑rolled implementation with
GenServer, exploring its callbacks, time‑outs, and extra features such ashandle_info/2and process registration. - Explain common pitfalls (e.g., mismatched callback arities) and best‑practice tools like the
@implattribute.
Motivation: Why a Generic Server?
Imagine you are building a chat application. Each chat room needs to store the list of participants, the recent messages, and some settings (e.g., whether the room is public). Every room will have the same lifecycle:
- Spawn a process that lives as long as the room exists.
- Maintain a state (participants, messages, etc.).
- Receive requests from clients: add a participant, post a message, query the list of participants, etc.
- Send a reply when a client asks for information.
If you write each room from scratch you would duplicate the same receive loop and state‑management code over and over. A generic server abstracts that boilerplate, letting you focus on the domain‑specific logic (how to add a participant, how to format a message) while reusing a proven loop implementation.
Core Explanation
1. The Minimal Set of Duties for a Server Process
A server process, regardless of the particular problem it solves, must handle five things:
- Spawn – create a new process.
- Loop – run indefinitely, constantly waiting for messages.
- State – keep an immutable data structure that is threaded through each loop iteration.
- Message dispatch – decide what to do based on the incoming payload.
- Response – optionally send a reply to the caller.
2. Plug‑in Architecture Using Modules
Elixir treats a module name as an atom, which means you can store a reference to a module in a variable and later call functions on it dynamically. This dynamic dispatch is the cornerstone of our plug‑in design.
my_mod = MyRoomLogic # `my_mod` holds the atom :MyRoomLogic
my_mod.start_room() # translates to MyRoomLogic.start_room()
We will call the module that contains the domain‑specific logic a callback module. The generic server will keep the callback module’s atom in its state and invoke the expected functions whenever it needs to delegate work.
3. Hand‑Rolling a Generic Server
3.1 Starting the Process and Initialising State
The first function, start/1, receives the callback module, spawns a new process, and hands the module an opportunity to create the initial state:
defmodule SimpleServer do
# Public API -------------------------------------------------------------
def start(callback_mod) do
spawn(fn ->
# Let the callback module decide the initial state.
init_state = callback_mod.init()
loop(callback_mod, init_state)
end)
end
# ----------------------------------------------------------------------
defp loop(callback_mod, state) do
receive do
# The generic server only knows the shape of the tuple; the
# callback module decides what to do with the request.
{request, caller} ->
{reply, new_state} = callback_mod.handle_call(request, state)
send(caller, {:reply, reply})
loop(callback_mod, new_state)
end
end
end
Notice the minimal contract we impose on the callback module:
init/0– returns the initial state.handle_call/2– receives a request and the current state, and returns{reply, new_state}.
3.2 Client‑Side Helper
Calling code should not need to know the internal message format. A tiny wrapper hides the details:
defmodule SimpleServer do
# ... previous code ...
def call(server_pid, request) do
send(server_pid, {request, self()})
receive do
{:reply, reply} -> reply
end
end
end
3.3 A Realistic Callback Example – A Mini Inventory
Let’s implement a simple inventory that lets workers add and fetch the quantity of a product.
defmodule Inventory do
# Called once when the server starts.
def init do
%{} # a map where keys are product IDs and values are counts
end
# Synchronous request: asking for the current stock.
def handle_call({:stock, product_id}, state) do
{Map.get(state, product_id, 0), state}
end
# Synchronous request: increase stock.
def handle_call({:add, product_id, amount}, state) do
new_state = Map.update(state, product_id, amount, &(&1 + amount))
{:ok, new_state}
end
end
Now we can spin up the server and talk to it:
iex> pid = SimpleServer.start(Inventory)
iex> SimpleServer.call(pid, {:add, :widget, 10})
:ok
iex> SimpleServer.call(pid, {:stock, :widget})
10
4. Adding Asynchronous Requests (Casts)
Often a client does not need a reply – it just wants to tell the server to do something. This “fire‑and‑forget” pattern is called a cast. To support it we extend the message shape and add a new callback function handle_cast/2.
4.1 Updating the Generic Server
defmodule SimpleServer do
# Public API -------------------------------------------------------------
def start(callback_mod), do: spawn(fn -> loop(callback_mod, callback_mod.init()) end)
def call(server_pid, request) do
send(server_pid, {:call, request, self()})
receive do
{:reply, reply} -> reply
end
end
def cast(server_pid, request) do
send(server_pid, {:cast, request})
:ok
end
# Internal loop ---------------------------------------------------------
defp loop(callback_mod, state) do
receive do
{:call, request, caller} ->
{reply, new_state} = callback_mod.handle_call(request, state)
send(caller, {:reply, reply})
loop(callback_mod, new_state)
{:cast, request} ->
new_state = callback_mod.handle_cast(request, state)
loop(callback_mod, new_state)
end
end
end
4.2 Updating the Callback Module
We keep the synchronous calls for queries, but turn stock‑increment into a cast because the client does not care about the immediate :ok response.
defmodule Inventory do
def init, do: %{}
# Synchronous query
def handle_call({:stock, product_id}, state) do
{Map.get(state, product_id, 0), state}
end
# Asynchronous update
def handle_cast({:add, product_id, amount}, state) do
Map.update(state, product_id, amount, &(&1 + amount))
end
end
Now the interaction becomes:
iex> pid = SimpleServer.start(Inventory)
iex> SimpleServer.cast(pid, {:add, :widget, 5})
:ok
iex> SimpleServer.call(pid, {:stock, :widget})
5
5. Transitioning to the Built‑In GenServer Behaviour
Our hand‑rolled version works, but it lacks many niceties that the OTP team has already built into GenServer:
- Automatic handling of process termination and error propagation.
- Configurable time‑outs for synchronous calls.
- Convenient functions for converting a module into a behaviour via
use GenServer. - Standardised callbacks (
init/1,handle_call/3,handle_cast/2,handle_info/2,terminate/2, ...).
5.1 Declaring a GenServer Module
To become a GenServer you simply use GenServer. This macro injects a set of default callbacks that you can override.
defmodule RoomServer do
use GenServer # pulls in the behaviour
# -------------------------------------------------------------------
# Public API – thin wrappers around GenServer functions
# -------------------------------------------------------------------
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, :ok, opts)
end
def join(pid, user) do
GenServer.cast(pid, {:join, user})
end
def post_message(pid, user, text) do
GenServer.cast(pid, {:msg, user, text})
end
def participants(pid) do
GenServer.call(pid, :participants)
end
def recent(pid) do
GenServer.call(pid, :recent)
end
# -------------------------------------------------------------------
# Callbacks required by the behaviour
# -------------------------------------------------------------------
@impl true # tells the compiler we are implementing a behaviour callback
def init(:ok) do
state = %{users: MapSet.new(), recent: []}
{:ok, state}
end
@impl true
def handle_cast({:join, user}, %{users: users} = state) do
new_state = %{state | users: MapSet.put(users, user)}
{:noreply, new_state}
end
@impl true
def handle_cast({:msg, user, text}, %{users: users, recent: msgs} = state) do
if MapSet.member?(users, user) do
new_state = %{state | recent: [{user, text} | msgs] |> Enum.take(10)}
{:noreply, new_state}
else
{:noreply, state} # ignore messages from unknown users
end
end
@impl true
def handle_call(:participants, _from, %{users: users} = state) do
{:reply, MapSet.to_list(users), state}
end
@impl true
def handle_call(:recent, _from, %{recent: msgs} = state) do
{:reply, Enum.reverse(msgs), state}
end
# Optional: handling a plain message (e.g., periodic cleanup)
@impl true
def handle_info(:cleanup, state) do
IO.puts("Cleaning up old messages…")
{:noreply, state}
end
end
Key points in this example:
start_link/1returns{:ok, pid}– the standard shape expected by supervisors.- All callbacks receive the current
stateand must return a tuple that tells GenServer what to do next. - The
@impl trueattribute is a compile‑time aid that checks we are indeed implementing a defined callback (more on this in the Pitfalls section). - We added a
handle_info/2clause to demonstrate how you can receive arbitrary messages (e.g., a recurring:cleanuptimer).
5.2 Using the GenServer‑Based Room
iex> {:ok, pid} = RoomServer.start_link(name: :my_room)
iex> RoomServer.join(pid, "alice")
:ok
iex> RoomServer.join(pid, "bob")
:ok
iex> RoomServer.post_message(pid, "alice", "Hello world!")
:ok
iex> RoomServer.participants(pid)
["alice", "bob"]
iex> RoomServer.recent(pid)
[{"alice", "Hello world!"}]
6. Advanced GenServer Topics
6.1 Time‑outs for Calls
By default GenServer.call/2 waits five seconds for a reply. You can pass a custom timeout as the third argument:
GenServer.call(pid, :expensive_query, 30_000) # wait 30 seconds
If the timeout elapses, the client raises a GenServer.CallError. This protects you from hanging indefinitely when a server crashes or runs into a deadlock.
6.2 Propagating Crashes to Callers
If a server crashes while a client is awaiting a call, the client process receives an exit signal and the GenServer.call/2 raises. This behavior is essential for building fault‑tolerant supervision trees: the supervisor can restart the crashed server, while the caller learns that its request failed.
6.3 Handling Plain Messages
Only call and cast messages are wrapped by the GenServer internals. Anything else you send (e.g., via send/2 or a timer) lands in handle_info/2. A classic use‑case is a periodic “tick” that triggers housekeeping.
defmodule Cleaner do
use GenServer
@cleanup_interval :timer.seconds(60)
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, :ok, opts)
end
@impl true
def init(:ok) do
:timer.send_interval(@cleanup_interval, :run_cleanup)
{:ok, %{}}
end
@impl true
def handle_info(:run_cleanup, state) do
IO.puts("[Cleaner] Running periodic cleanup")
# …perform cleanup actions here…
{:noreply, state}
end
end
6.4 Registering a Server Under a Name
Often you only need a single instance of a server (a singleton). You can register it under a local atom, making lookup trivial:
GenServer.start_link(RoomServer, :ok, name: :global_chat)
# Later…
RoomServer.join(:global_chat, "charlie")
Using a name eliminates the need to pass around the PID, especially when multiple parts of your application need to interact with the same process.
7. Common Patterns with GenServer
- State as a map or struct – keep your data organised and self‑documenting.
- Separate API module – expose only the functions you want clients to use, hiding the GenServer internals.
- Supervision tree – wrap every GenServer in a
Supervisorso it can be automatically restarted on failure. - Message filtering – use pattern matching in
handle_call/3andhandle_cast/2to cleanly separate concerns.
Pitfalls and How to Avoid Them
7.1 Wrong Callback Arity
The OTP framework expects callbacks with exact arities (number of arguments). A common mistake is writing handle_call/2 when GenServer requires handle_call/3. The code compiles, but the generic server never finds the function, leading to a runtime error like:
** (RuntimeError) attempted to call GenServer #PID<0.123.0> but no handle_call/3 clause was provided
Solution: always enable the @impl attribute before a callback definition. The compiler checks that the function signature matches the behaviour:
@impl true
def handle_call(request, _from, state) do
# ...
end
7.2 Forgetting to Return the Expected Tuple
Each callback must return a specific tuple shape:
{:reply, reply, new_state}(or{:reply, reply, new_state, timeout}) fromhandle_call/3.{:noreply, new_state}fromhandle_cast/2andhandle_info/2.{:ok, new_state}frominit/1.
Returning a plain value (e.g., just new_state) will cause a FunctionClauseError inside the GenServer framework.
7.3 Blocking the Server Process
Never perform long‑running, blocking work directly inside a callback. The server process will not be able to receive other messages while it is busy, causing back‑pressure for the whole system.
Instead, delegate heavy work to a separate process (perhaps a Task or a pool of workers) and have the server receive a message when the work is done.
7.4 Unbounded State Growth
Because state is immutable, each change creates a new version of the data structure. If you keep appending to a list without ever truncating it, memory usage will grow indefinitely. Use structures like :queue, Map with limited size, or a ring buffer to keep only the recent N items (as shown in the RoomServer example).
Summary
We have walked through the entire lifecycle of creating a generic server process in Elixir:
- Identify the universal responsibilities of a server (spawn, loop, state, message routing, optional reply).
- Implement a reusable boilerplate that delegates domain‑specific logic to a callback module.
- Extend the primitive server to handle both
call(synchronous) andcast(asynchronous) messages. - Replace the custom implementation with the battle‑tested
GenServerbehaviour, learning its required callbacks, the@implattribute, and its built‑in helpers. - Explore advanced features such as time‑outs, crash propagation, handling plain messages via
handle_info/2, and process registration. - Highlight common pitfalls (wrong arity, missing return tuples, blocking the server, unbounded state) and best practices to avoid them.
By mastering this pattern you gain a powerful tool for structuring concurrent Elixir applications. Whether you are building a chat room, an inventory system, a telemetry collector, or any other stateful service, the generic server abstraction – especially the GenServer behaviour – gives you a clean, reliable, and idiomatic way to encapsulate state and coordination logic.