Turning State into a Service: How GenServer Solves Concurrency in Elixir
GenServer turns a module into a concurrent service that encapsulates state, handles messages, and integrates with OTP supervision. This blog walks through a counter example, discusses trade‑offs, and offers actionable steps for Elixir developers.
19 Mar 2026, 03:55 UTC

The Concurrency Challenge in Elixir
In many web applications you need a shared counter, a cache, or a queue that can be updated from multiple request handlers simultaneously. In a language that runs a single thread per process, naive shared state can become a bottleneck or a source of race conditions. Elixir gives you a clean way to isolate state: each process is a sandboxed thread of execution with its own memory. The question then is how to expose that state to the rest of the system in a safe, fault‑tolerant way.
GenServer: A Lightweight, OTP‑Backed Service
GenServer is an OTP (Open Telecom Platform) behaviour that turns a module into a concurrent service. It provides:
- Encapsulated state – the process owns the data, preventing accidental sharing.
- Message handling –
call(synchronous) andcast(asynchronous) allow you to interact with the service. - Supervision integration – when you drop a GenServer under a
Supervisor, it restarts on crash automatically. - Pattern matching API – the compiler checks arity and you can document intent directly in the function heads.
Because BEAM processes are lightweight (a few kilobytes), you can spin up hundreds or thousands of GenServers without exhausting resources. However, each process still carries a message queue, so extremely high‑volume scenarios may need careful design.
Building a Counter Service: A Worked Example
Below is a minimal counter that increments on each cast and returns the current value on call. The example demonstrates:
- Defining a GenServer module.
- Starting it under a supervisor.
- Sending casts and calls.
- Using
sys:get_state/1to inspect the internal state. - Observing crash recovery.
defmodule Counter do
use GenServer
# Client API
def start_link(opts \ []) do
GenServer.start_link(__MODULE__, 0, opts)
end
def increment(pid) do
GenServer.cast(pid, :increment)
end
def value(pid) do
GenServer.call(pid, :value)
end
# Server callbacks
def init(initial) do
{:ok, initial}
end
def handle_cast(:increment, state) do
{:noreply, state + 1}
end
def handle_call(:value, _from, state) do
{:reply, state, state}
end
end
# Supervisor configuration
children = [
{Counter, name: Counter}
]
Supervisor.start_link(children, strategy: :one_for_one)
# Usage
pid = Counter.start_link(name: :counter)
Counter.increment(pid)
Counter.increment(pid)
IO.puts("Current value: #{Counter.value(pid)}") # => 2
# Inspect state
IO.inspect(GenServer.call(pid, :value))
# Crash and restart
Process.exit(pid, :kill)
# After a brief moment, the supervisor restarts the counter
IO.puts("After crash, value: #{Counter.value(pid)}") # => 0
Key points to verify:
- After two
incrementcasts,value/1should return2. - Calling
GenServer.call/2with:valueshould give the same result. - When the process receives
:kill, the supervisor should restart it with the initial state (0).
To double‑check, run iex -S mix, load the module, and execute the snippet. Watch the supervisor logs to confirm the restart.
Trade‑offs and When to Use Alternatives
While GenServer shines for most stateful services, it is not a silver bullet:
- Memory overhead – each process has a small heap and message queue. For a counter that needs to handle millions of updates per second, an
Agentor aGenServerbacked by ETS (Erlang Term Storage) might be more efficient. - Boilerplate – you must implement
init/1,handle_call/3,handle_cast/2, etc. For trivial state, a plain module with amaporAgentcan be quicker. - Debugging complexity – crashes inside a GenServer surface as stack traces. If you rely heavily on dynamic supervision, you may need to inspect logs to pinpoint recurring faults.
Benchmarks from Benchee often show that a GenServer handling 10k cast operations per second is comparable to an Agent, but the difference grows when you add supervision and crash‑recovery logic.
Next Steps: Integrating GenServer into Your Project
1. Identify the stateful component: counters, caches, or queues.
2. Decide if you need supervision; if yes, wrap the GenServer in a Supervisor.
3. Use GenServer.call/2 for operations that need a response and cast/2 for fire‑and‑forget updates.
4. Add unit tests that simulate concurrent casts and calls to ensure thread‑safe behaviour.
5. Monitor the process with observer or to track message queue length and restart frequency.
By following this pattern, you turn a simple shared variable into a robust, fault‑tolerant service that can scale with your application’s traffic. Happy coding!
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.