Designing a Stateful Worker in Elixir: When One GenServer Is the Right Answer
An architecture note on using a single supervised GenServer for serialized state in Elixir: requirements, a minimal implementation, trust boundaries, production checks, and the conditions that should push you to ETS, sharding, or distribution.
26 Aug 2026, 07:37 UTC

The problem this design solves
You have state that must be mutated safely at runtime — a rate-limit counter, an in-flight job registry, a connection lease table — and you need exactly one writer so concurrent callers can't corrupt it. In Elixir, the smallest suitable answer is usually a single GenServer under a Supervisor, not an ETS table with ad-hoc locking, not a database round-trip, and definitely not an Agent hidden inside a library module. The takeaway: start with one supervised GenServer with a narrow public API, and only reach for distribution or ETS when a measured bottleneck or a multi-node requirement forces you to.
Requirements that justify a GenServer
A GenServer earns its place when all of these hold:
- The state fits comfortably in memory (kilobytes to a few megabytes, not gigabytes).
- Operations must be serialized — order matters, or reads must observe a consistent snapshot.
- The state is transient or reconstructable; losing it on restart is acceptable or recoverable.
- Throughput is modest enough that sequential message processing isn't the bottleneck (a single GenServer on modern hardware typically handles tens of thousands of short operations per second, but measure your own workload).
If any of these fail — huge datasets, cross-node sharing, read-heavy workloads — the design changes, and the last section covers how.
The smallest suitable design
One module, one Supervisor entry, a public API that hides the process entirely. Callers should never see a PID or a raw GenServer.call/3.
defmodule MyApp.LeaseRegistry do
use GenServer
# Public API — the only way to touch the state
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def acquire(lease_id, owner), do: GenServer.call(__MODULE__, {:acquire, lease_id, owner})
def release(lease_id), do: GenServer.cast(__MODULE__, {:release, lease_id})
@impl true
def init(_opts), do: {:ok, %{leases: %{}}}
@impl true
def handle_call({:acquire, id, owner}, _from, state) do
case Map.fetch(state.leases, id) do
:error ->
{:reply, :ok, put_in(state.leases[id], owner)}
{:ok, _existing} ->
{:reply, {:error, :already_held}, state}
end
end
@impl true
def handle_cast({:release, id}, state) do
{:noreply, update_in(state.leases, &Map.delete(&1, id))}
end
endRegister it under your application supervisor in lib/my_app/application.ex:
children = [
MyApp.LeaseRegistry
]
Supervisor.start_link(children, strategy: :one_for_one)Two deliberate choices here. First, the process is registered under a module name (name: __MODULE__), so callers don't need to discover a PID and the Supervisor can restart it transparently. Second, acquire is a synchronous call because the caller needs the verdict, while release is a fire-and-forget cast because no response is meaningful. That split — calls only where a reply carries information — keeps the mailbox small under load.
Trust and data boundaries
Elixir processes share nothing. The map inside init/1 is private to this process; no other code can read or mutate it except by sending a message the server chooses to handle. That is your entire trust boundary, and it's a strong one — but it has two consequences worth respecting.
First, anything you return from handle_call is copied into the caller's process. Returning the whole state map for "debugging" hands out a snapshot that's immediately stale and costs a copy proportional to state size. Expose targeted queries instead. Second, the boundary protects state, not callers: a malformed message that crashes a handle_* clause takes the whole process down. Validate at the API edge if the input is untrusted, or accept the crash and let the Supervisor restore a clean initial state.
Operational checks
Two checks catch most real problems. Run them in a remote console (iex --remsh or bin/my_app remote in a release) against the running node; they require no special permissions beyond shell access to the node.
Mailbox depth. A GenServer processes messages one at a time, so a growing queue means it's falling behind:
Process.info(MyApp.LeaseRegistry, :message_queue_len)
# => {:message_queue_len, 0}Any value that climbs and doesn't drain under sustained load is your early warning for memory exhaustion — the mailbox is unbounded. Second, full status including current state:
:sys.get_status(MyApp.LeaseRegistry)This is safe to run in production for a quick look, but avoid polling it in a tight loop on a busy process since it queues a system message like any other.
To verify restart behavior works as designed, kill the process in a dev or staging console and confirm the Supervisor brings it back with initial state:
Process.exit(Process.whereis(MyApp.LeaseRegistry), :kill)
Process.alive?(Process.whereis(MyApp.LeaseRegistry)) # => true, new PIDExpect the state to be reset — that's the point of "let it crash": a restart returns you to a known-good initial state rather than limping along with corrupted data. If your state must survive restarts, that's a requirement change, not a tuning problem; see below.
Failure modes to design around
- Circular calls. If GenServer A calls B and B calls A, both block forever (or until the 5-second default call timeout fires). Rule: a GenServer may cast to anyone, but should only call processes that never call it back.
- Slow callbacks. A
handle_callthat does network I/O blocks every other caller. Move slow work out (spawn a Task, reply later viaGenServer.reply/2) or accept the serialization. - Large state. Very large maps or binaries in state can lengthen garbage-collection pauses since the process heap is collected as a unit. Keep state small and reference external storage for bulk data.
- Restart loops. If the crash-causing message is re-sent immediately after restart, the Supervisor gives up after its intensity budget (default: 3 restarts in 5 seconds) and takes down its siblings. Fix the crash source; don't just raise the budget.
Conditions that would change the design
Revisit this architecture when any of these become true:
- Read-heavy load: move reads to an ETS table owned by the GenServer (writes still serialized through it, reads concurrent via
:ets.lookup/2). - Multi-node state: a named GenServer is local to one node. Cross-node visibility calls for Phoenix.PubSub to broadcast changes, a global registry (
:globalor Horde), or an external store — each with real consistency trade-offs. - State must survive crashes: persist on change (disk, database) and rebuild in
init/1, accepting the write latency. - Measured throughput ceiling: partition into N GenServers keyed by a hash of the ID (a "shard per bucket" pattern) rather than one global process.
Until one of those shows up in a measurement or a requirement, the single supervised GenServer with a narrow API is the right amount of machinery — easy to reason about, easy to observe, and cheap to replace when it stops fitting.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.