GenServer in Practice: Managing Concurrent State in Elixir Without Locks
GenServer serializes access to state without locks. This post builds a working key-value store, puts it under supervision, and explains when a single process becomes the bottleneck.
17 Jun 2026, 05:26 UTC

If you come to Elixir from a language with threads and mutexes, the first question is usually: where does shared state live, and who locks it? Elixir's answer is the GenServer — a single process that owns a piece of state and answers messages one at a time. No locks, no race conditions, and a supervision tree that restarts it when things go wrong. This post walks through what GenServer actually gives you, builds a small working key-value store, and then talks honestly about when this pattern is the wrong tool.
The problem GenServer solves
Elixir processes are cheap, isolated units of concurrency that communicate only by passing messages. That's great for safety, but raw receive loops get tedious fast: you have to handle timeouts, monitor callers, deal with system messages, and support hot code upgrades yourself. GenServer is an OTP behaviour — a contract plus a framework-provided loop — that handles all of that plumbing. You write callbacks; OTP invokes them.
The mental model is simple: one process, one mailbox, one piece of state. Messages are processed sequentially, so state transitions are naturally serialized. If two clients try to update the same key at the same time, one message simply arrives first.
The callbacks you'll actually write
A GenServer module implements a handful of callbacks. In practice you'll touch these most:
init/1— called once when the process starts; returns the initial state.handle_call/3— synchronous request/response. The caller blocks until you reply (or a 5-second default timeout fires).handle_cast/2— asynchronous fire-and-forget. No reply.handle_info/2— everything else that lands in the mailbox: monitor messages, timer ticks, stray sends.
terminate/2 and code_change/3 exist too, but terminate/2 only runs for clean shutdowns (not brutal supervisor kills), so don't rely on it as your only persistence point.
A worked example: a tiny key-value store
Here's a complete, conventional implementation. It assumes a recent Elixir/OTP (1.14+ / OTP 25+), though this API has been stable for years.
defmodule KVStore do
use GenServer
# --- Client API ---
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, Keyword.merge([name: __MODULE__], opts))
end
def get(key), do: GenServer.call(__MODULE__, {:get, key})
def put(key, value), do: GenServer.cast(__MODULE__, {:put, key, value})
# --- Server callbacks ---
@impl true
def init(state), do: {:ok, state}
@impl true
def handle_call({:get, key}, _from, state) do
{:reply, Map.get(state, key), state}
end
@impl true
def handle_cast({:put, key, value}, state) do
{:noreply, Map.put(state, key, value)}
end
@impl true
def handle_info(_unexpected, state), do: {:noreply, state}
endNote the catch-all handle_info/2. Without it, an unexpected message (say, a stray :DOWN from a monitor you forgot about) crashes the process with a function clause error. Swallowing unknown messages is usually safer — but log them in real systems so you notice bugs.
To try it, create a project with mix new kv_store, drop the module into lib/, and start a session with iex -S mix (run from the project root; no special permissions needed):
iex> {:ok, _pid} = KVStore.start_link()
iex> KVStore.put(:color, "blue")
:ok
iex> KVStore.get(:color)
"blue"
iex> KVStore.get(:missing)
nilNotice that put/2 returns immediately (:ok just means the cast was sent, not processed), while get/1 blocks for the reply. That asymmetry matters: casts are faster but give you no backpressure or confirmation.
Putting it under supervision
The real payoff is fault tolerance. Add the server to your application's supervision tree in lib/kv_store/application.ex:
children = [
KVStore
]
Supervisor.start_link(children, strategy: :one_for_one)Because start_link/1 is defined, the supervisor can restart the process if it crashes. One caveat: the restarted server gets the state returned by init/1 — an empty map here. "Restart with last known state" is not automatic; if durability matters, recover from persistent storage (ETS, a file, a database) in init/1.
To verify behavior, mix test with an ExUnit case that starts the server, asserts replies, and — if you want to check supervision — kills the process with Process.exit(pid, :kill) and confirms a new pid answers calls afterward.
The trade-off: one process, one mailbox
Sequential processing is both the feature and the limitation. A single GenServer handles one message at a time, so under heavy load it becomes a throughput ceiling and a latency queue. If each handle_call takes 1 ms, your server caps out around 1,000 calls per second regardless of how many cores you have.
Before reaching for workarounds, measure. If a GenServer genuinely is the bottleneck, common escapes are:
- ETS tables — allow concurrent reads/writes from many processes; a GenServer can own the table while clients read it directly.
- Sharding — run a pool of GenServers (e.g., keyed by hash) via a
Registrywithkeys: :uniqueor:duplicate. - Doing less in the callback — reply quickly and offload heavy work, being careful not to break ordering guarantees you depend on.
A second, quieter risk is unbounded state growth. A GenServer accumulating data in a map with no eviction policy is a memory leak that supervision won't fix — restarts just reset the clock. Pair long-lived servers with explicit size limits or TTLs.
Where to go from here
Start with the boring version: one GenServer, a client API module, supervision from your application tree, and ExUnit tests that exercise call/cast and a crash-restart cycle. Verify with mix test and a quick iex -S mix session before assuming anything works. Only reach for ETS or sharding when you have a measurement showing the single process is the constraint — premature distribution is its own class of bug.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.