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.

GenServer lifecycle: start_link calls init, then handle_call and handle_cast process messages, handle_info receives out-of-band messages, and handle_continue chains work
Fig. 1 — Calls block the caller; casts do not. Every callback returns the next state, and crashes are handled by the supervisor, not the server.
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?

MechanismBlockingBack-pressureUse for
handle_callcaller waitsnatural (mailbox + timeout)reads, writes that must be confirmed
handle_castnonone — floods the mailboxfire-and-forget the caller must not wait on
handle_infon/amailboxtimers (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 — call to itself deadlocks; use handle_continue or send.
  • Name registration: :global for one-per-cluster, Registry for one-per-entity (see Distributed Elixir).

Practice

  1. Run the KV server above in iex: put three keys, get one, then clear_async and read again.
  2. Add a handle_call(:size) callback and a 1-second send_after expiry in handle_info.
  3. 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