Welcome to an exploration of Elixir GenServers. Prepare yourselves, because we're about to journey into one of the best features of the Elixir world – a cornerstone upon which you can build everything from distributed chatbots that quote Shakespeare on message arrival, to stateful APIs that remember exactly how many espressos you've consumed this week... or maybe just poorly structured code.
These are entities that embody concurrency and resilience! They are a bedrock of Elixir applications – these tireless workers running in their own processes, handling everything from critical system failures, caching, and more. Think of them as digital bouncers, state managers, message routers, and occasionally terrible logic traps, all wrapped into a delightfully robust, functional powerhouse.
A Simple GenServer
Lets build a simple GenServer that does a whole lot of nothing useful. First we need an Elixir project. So lets set one up with mix
.
If you don't have Elixir and Mix and happen to be on NixOS here is a great walk-through on getting setup with Flake based Nix Shells.
mix new simple_gen_server
cd simple_gen_server
GenServer Module
Clear out and replace the file at lib/simple_gen_server.ex
.
defmodule SimpleGenServer do
use GenServer
@impl true
def init(_), do: {:ok, %{people: []}}
@impl true
def handle_call({:get, id}, _from, state) do
person = Enum.find(state.people, &(&1.id == id))
{:reply, person, state}
end
@impl true
def handle_cast({:register, person}, state) do
state = update_in(state.people, &[person | &1])
{:noreply, state}
end
end
simple_gen_server.ex
Defining a basic GenServer revolves around three key functions:
- init/1: Takes initial arguments to set up the GenServer's starting state. In our case? Not needed at all – We pass an empty list
[ ]
. - handle_call/3: This is for messages that require a response. Think of it as the GenServer's polite chat function –
handle_call/3
processes the call (like our get) and the caller will wait for the GenServer to respond. - handle_cast/1: For those fire-and-forget messages where you don't care if the sender is immediately available.
handle_cast/2
handles our register, letting us add people without waiting for confirmation – asynchronously, naturally.
Testing the GenServer
We can load the code into IEX with iex -S mix
. Once inside the REPL we can interact with our GenServer.
iex(1)> {:ok, pid} = GenServer.start_link(SimpleGenServer, [])
{:ok, #PID<0.150.0>}
iex(2)> GenServer.call(pid, {:get, 1})
nil
iex(3)> GenServer.cast(pid, {:register, %{id: 1, name: "Alice"}})
:ok
iex(4)> GenServer.call(pid, {:get, 1})
%{id: 1, name: "Alice"}
iex
You can instantiate one as a self-contained unit within your sprawling application ecosystem. Give it a task and let it hoard data relevant to that specific task, like some poor developer working out of a ticket queue from a basement. Any corner of your codebase can just lob messages at its virtual doorstep – asynchronously – knowing it will handle them methodically.
It’s not an external add-on or library; the language itself provides the mailbox machinery and enforces this single-message-at-a-time processing regime. And you know what? Less than a dozen lines later, you're managing distributed state purely through message passing.
And that's without discussing that they have built in crash handling and resilience.
Adding an External API
Honestly, relying on direct calls like GenServer.cast(pid, {get, 1})
is just... suboptimal.
It's not ideal for code clarity — and frankly, its ugly. If we wanted ugly code we could write some Java. To make things cleaner and more abstracted away from the GenServer internals, we should define a public API with functions such as:
register_person
get_person
def register_person(pid, id, name),
do: GenServer.cast(pid, {:register, %{id: id, name: name}})
def get_person(pid, id),
do: GenServer.call(pid, {:get, id})
simple_gen_server.ex
Lets try this out with iex -S mix
.
iex(2)> {:ok, pid} = GenServer.start_link(SimpleGenServer, [])
{:ok, #PID<0.151.0>}
iex(3)> SimpleGenServer.register_person(pid, 1, "Alice")
:ok
iex(4)> SimpleGenServer.get_person(pid, 1)
%{id: 1, name: "Alice"}
iex
But don't get too comfortable – you're still dragging the GenServers' PID around with you. In a lesser language we would have to develop some kind of harness to carry the PID around with the rest of our baggage. We can register a GenServer with its very own name and access it globally via a registry.
Accessing GenServers by Name
GenServers can be accessed by name instead of process ID. This eliminates the need to pass around its process reference everywhere in your code.
defmodule SimpleGenServer do
use GenServer
@impl true
def init(_), do: {:ok, %{people: []}}
@impl true
def handle_call({:get, id}, _from, state) do
person = Enum.find(state.people, &(&1.id == id))
{:reply, person, state}
end
@impl true
def handle_cast({:register, person}, state) do
state = update_in(state.people, &[person | &1])
{:noreply, state}
end
def start() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def register(id, name),
do: GenServer.cast(__MODULE__, {:register, %{id: id, name: name}})
def get(id),
do: GenServer.call(__MODULE__, {:get, id})
end
simple_gen_server.ex
We can test the much cleaner API now, with the GenServer internals completely abstracted away.
iex(2)> SimpleGenServer.start
{:ok, #PID<0.150.0>}
iex(3)> SimpleGenServer.register(1, "Alice")
:ok
iex(4)> SimpleGenServer.get(1)
%{id: 1, name: "Alice"}
iex(5)>
iex
Further Reading

Official GenServer Documentation