Elixir: GenServer
GenServer ("generic server") extracts the receive-loop pattern you hand-rolled in
The Process Model into a battle-tested behaviour with
timeouts, error propagation, and debugging built in. Most stateful processes in real systems are
GenServers.
The Client/Server Split
A GenServer module has two halves. The client API is the functions other code calls; the server callbacks run inside the process. Keeping them visually separated is the single most important style rule for readable servers.
defmodule KV do
# ── Client API ────────────────────────────────────────────
def start_link(opts \\\\ []) do
# `name:` registers the server so calls don't need the pid.
GenServer.start_link(__MODULE__, %{}, name: Keyword.get(opts, :name, __MODULE__))
end
# call = synchronous: caller BLOCKS until the reply (with timeout).
def put(key, value), do: GenServer.call(__MODULE__, {:put, key, value})
def get(key), do: GenServer.call(__MODULE__, {:get, key})
# cast = asynchronous: returns :ok immediately, no delivery guarantee.
def clear_async, do: GenServer.cast(__MODULE__, :clear)
# ── Server callbacks (@impl documents they're behaviour callbacks) ──
@impl true
def init(state) do
# handle_continue defers heavy work until AFTER start_link returns,
# so a slow boot does not block the supervision tree.
{:ok, state, {:continue, :warmup}}
end
@impl true
def handle_continue(:warmup, state) do
{:ok, state} # ... load caches here in real code
end
@impl true
def handle_call({:put, key, value}, _from, state) do
{:reply, :ok, Map.put(state, key, value)} # {reply, new_state}
end
@impl true
def handle_call({:get, key}, _from, state) do
{:reply, Map.fetch(state, key), state} # state unchanged
end
@impl true
def handle_cast(:clear, _state), do: {:noreply, %{}}
@impl true
# handle_info receives EVERYTHING else: monitors, timers, raw sends.
def handle_info(:expire, state) do
{:noreply, Map.drop(state, expired_keys(state))}
end
end
call, cast, or info?
| Mechanism | Blocking | Back-pressure | Use for |
|---|---|---|---|
handle_call | caller waits | natural (mailbox + timeout) | reads, writes that must be confirmed |
handle_cast | no | none — floods the mailbox | fire-and-forget the caller must not wait on |
handle_info | n/a | mailbox | timers (Process.send_after), monitors, raw messages |
Default advice: reach for call. It gives the caller a timeout (default 5s) and makes
overload visible; a cast storm is invisible until the mailbox grows.
Timeouts and Process Recovery
# Every call has a timeout — make it explicit at the boundaries.
GenServer.call(__MODULE__, {:get, :hot_key}, 1_000)
# A callback can also set a timer for the server itself:
@impl true
def handle_call(:start_batch, _from, state) do
# Re-deliver :expire to ourselves in 60s — no polling loops.
Process.send_after(self(), :expire, :timer.seconds(60))
{:reply, :started, state}
end
If a callback crashes, the standard reaction is the supervisor's: the server restarts with
init/1 and its state is gone. That loss is the design constraint
that shapes real systems — persist state you cannot afford to lose (ETS, Ecto, disk) and keep the
process state rebuildable.
State Design Guidelines
- Model state as a struct with a version field if it can be migrated on upgrade.
- Keep invariants inside the server: the state is only mutated by its own callbacks, so each invariant is checked in exactly one place.
- Never call your own client API from inside the server —
callto itself deadlocks; usehandle_continueorsend. - Name registration:
:globalfor one-per-cluster,Registryfor one-per-entity (see Distributed Elixir).
Practice
- Run the KV server above in
iex:putthree keys,getone, thenclear_asyncand read again. - Add a
handle_call(:size)callback and a 1-secondsend_afterexpiry inhandle_info. - Crash the server with a bad message and prove the state is lost after restart — then persist the map to ETS so a restart can rebuild it.
Next: Supervision Trees