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 as handle_info/2 and process registration.
  • Explain common pitfalls (e.g., mismatched callback arities) and best‑practice tools like the @impl attribute.

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:

  1. Spawn a process that lives as long as the room exists.
  2. Maintain a state (participants, messages, etc.).
  3. Receive requests from clients: add a participant, post a message, query the list of participants, etc.
  4. 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/1 returns {:ok, pid} – the standard shape expected by supervisors.
  • All callbacks receive the current state and must return a tuple that tells GenServer what to do next.
  • The @impl true attribute 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/2 clause to demonstrate how you can receive arbitrary messages (e.g., a recurring :cleanup timer).

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 Supervisor so it can be automatically restarted on failure.
  • Message filtering – use pattern matching in handle_call/3 and handle_cast/2 to 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}) from handle_call/3.
  • {:noreply, new_state} from handle_cast/2 and handle_info/2.
  • {:ok, new_state} from init/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) and cast (asynchronous) messages.
  • Replace the custom implementation with the battle‑tested GenServer behaviour, learning its required callbacks, the @impl attribute, 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.