GenServer Fundamentals
GenServer is the workhorse of OTP. When you need a process that holds state, handles requests, and stays alive between calls, you reach for GenServer. Discord runs millions of these to manage chat sessions. WhatsApp uses them to track every connected user. Pinterest's notification service is built on top of them. Once you understand GenServer, you understand most of what makes Elixir applications tick.
The core idea is simple: a GenServer is a long-running process that receives messages, updates some internal state, and responds. You don't write the message loop yourself — OTP does that for you, and it does it correctly, with proper trapping of exits, timeouts, debugging hooks, and hot code reloading. You just fill in the callbacks.
The Callbacks That Matter
A GenServer has six callbacks you'll actually use, but only four show up regularly: init/1, handle_call/3, handle_cast/2, and handle_info/2. Everything else is plumbing.
defmodule Counter do
use GenServer
# Client API
def start_link(initial \\ 0) do
GenServer.start_link(__MODULE__, initial, name: __MODULE__)
end
def increment, do: GenServer.cast(__MODULE__, :increment)
def value, do: GenServer.call(__MODULE__, :value)
# Server callbacks
@impl true
def init(initial) do
{:ok, initial}
end
@impl true
def handle_call(:value, _from, count) do
{:reply, count, count}
end
@impl true
def handle_cast(:increment, count) do
{:noreply, count + 1}
end
end
That's a working counter. Start it with Counter.start_link(), call Counter.increment() from any process, and Counter.value() returns the current count. The GenServer serializes all messages, so you never have race conditions on the state.
init/1
init/1 runs when the process starts. It receives whatever you passed as the second argument to start_link. It must return {:ok, state} (or {:stop, reason} if startup should fail).
Keep init/1 fast. The process that called start_link blocks until init/1 returns. If you need to do slow work — fetching data from an API, loading a large file — return {:ok, state, {:continue, term}} and do the slow work in handle_continue/2:
def init(_args) do
{:ok, %{cache: nil}, {:continue, :load_cache}}
end
def handle_continue(:load_cache, state) do
data = ExpensiveLoader.load_everything()
{:noreply, %{state | cache: data}}
end
This pattern matters because supervisors will time out if init/1 takes too long, and your whole app fails to start. Pinterest's engineers wrote about this exact issue when they migrated their notification system — moving slow work to handle_continue/2 cut their boot time dramatically.
handle_call vs handle_cast
call is synchronous. The caller waits for a reply. cast is fire-and-forget. The caller returns immediately and never knows whether the message was processed.
Use call when you need the result. Use cast when you don't, and when losing the message in a crash is acceptable. Most production code uses call more than people initially expect — knowing that an operation completed is usually worth the synchronous overhead.
# call — caller blocks until handle_call returns
def get_user(id), do: GenServer.call(__MODULE__, {:get, id})
def handle_call({:get, id}, _from, state) do
{:reply, Map.get(state.users, id), state}
end
# cast — caller returns immediately
def log_event(event), do: GenServer.cast(__MODULE__, {:log, event})
def handle_cast({:log, event}, state) do
{:noreply, %{state | events: [event | state.events]}}
end
The _from parameter in handle_call is a tuple of {pid, ref}. You almost never use it directly, but you can stash it and reply later with GenServer.reply/2 — useful for proxying calls to external services without blocking the GenServer.
handle_info/2
This catches everything that isn't a call or cast: regular messages sent with send/2, monitor notifications, timer messages from Process.send_after/3, and so on.
def init(state) do
Process.send_after(self(), :tick, 1_000)
{:ok, state}
end
def handle_info(:tick, state) do
IO.puts("tick")
Process.send_after(self(), :tick, 1_000)
{:noreply, state}
end
If you don't define a handle_info/2, the default implementation logs a warning for every unexpected message. That's fine, but it gets noisy fast — always handle the messages you expect, and add a catch-all if you genuinely don't care about the rest.
A Real Example: Token Bucket Rate Limiter
Counters are toy examples. Here's something you'd actually deploy — a per-key token bucket rate limiter, the kind every API gateway needs.
defmodule RateLimiter do
use GenServer
@refill_interval 1_000
# Client API
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def check(key, cost \\ 1) do
GenServer.call(__MODULE__, {:check, key, cost})
end
# Server callbacks
@impl true
def init(opts) do
capacity = Keyword.fetch!(opts, :capacity)
refill_rate = Keyword.fetch!(opts, :refill_rate)
schedule_refill()
{:ok, %{
buckets: %{},
capacity: capacity,
refill_rate: refill_rate
}}
end
@impl true
def handle_call({:check, key, cost}, _from, state) do
bucket = Map.get(state.buckets, key, state.capacity)
if bucket >= cost do
new_buckets = Map.put(state.buckets, key, bucket - cost)
{:reply, :ok, %{state | buckets: new_buckets}}
else
{:reply, {:error, :rate_limited}, state}
end
end
@impl true
def handle_info(:refill, state) do
new_buckets =
state.buckets
|> Enum.map(fn {key, tokens} ->
{key, min(state.capacity, tokens + state.refill_rate)}
end)
|> Enum.into(%{})
schedule_refill()
{:noreply, %{state | buckets: new_buckets}}
end
defp schedule_refill do
Process.send_after(self(), :refill, @refill_interval)
end
end
Usage:
{:ok, _} = RateLimiter.start_link(capacity: 10, refill_rate: 2)
case RateLimiter.check("user:42") do
:ok -> handle_request()
{:error, :rate_limited} -> {:error, "slow down"}
end
A single GenServer can comfortably handle tens of thousands of these checks per second on a modern machine. When you need more throughput, you shard across multiple GenServers using :erlang.phash2(key, num_shards) to pick which one handles a given key.
State Management
GenServer state is just a term. Maps are the most common choice because they're flexible, but for performance-critical paths you might use records, structs, or even a tuple. The state you return from a callback replaces the previous state — there's no merging.
def handle_cast({:set, key, value}, state) do
{:noreply, Map.put(state, key, value)}
end
If your state grows unbounded, you have a problem. Long-running GenServers that accumulate messages, cache entries, or events without eviction will eventually exhaust memory. The fix is usually one of: a TTL with periodic cleanup via handle_info(:cleanup, state), an LRU bounded by size, or moving the data to ETS (which doesn't count against the GenServer's heap).
For state that needs to survive crashes, persist it before returning from the callback that mutates it. The supervisor will restart your GenServer with fresh state from init/1, which is the right behavior — but only if init/1 knows where to load from.
Naming and Discovery
The name: __MODULE__ option in start_link registers the process under that name globally on the node. From then on, you can use the module name instead of a pid in any GenServer call.
For multiple instances, use name: {:via, Registry, {MyRegistry, "user:42"}} with a Registry. We cover this pattern in the OTP patterns chapter.
Common Pitfalls
Doing slow work in handle_call/3. While the GenServer is processing one call, every other call queues up. A 200ms HTTP request inside handle_call/3 means your throughput tops out at 5 requests per second. Either move the work to the caller, spawn a Task, or use handle_continue/2 for async follow-up work.
Using GenServer when you don't need state. If your "GenServer" just wraps a stateless function, delete it. Modules with regular functions are faster, simpler, and easier to test. Reach for GenServer when you genuinely need a serialized point of access to mutable state.
Forgetting that cast/2 lies. A successful cast only means the message was queued, not that anything happened. If the GenServer crashes before processing it, the message is lost and the caller will never know. For anything important, use call.
Treating GenServer as an object. GenServer is a process pattern, not a class. You don't instantiate one per piece of data. One GenServer typically manages many things — like our rate limiter handling thousands of keys in a single process.
Letting handle_info/2 blow up on unexpected messages. Any process can send any message to your GenServer. If you crash on unrecognized messages, you've created a denial-of-service vector. Always have a catch-all handle_info(_msg, state), do: {:noreply, state} unless you have a specific reason not to.
Key Takeaways
- GenServer is a process with state and a message handler —
init,handle_call,handle_cast,handle_infoare the callbacks you fill in. callis synchronous,castis fire-and-forget. Usecallmore often than feels natural.- Keep
init/1fast — push slow work tohandle_continue/2. - One GenServer can manage many keys or entities. Don't spawn one per item unless you actually need isolation.
- State is just a term that gets replaced on each callback return. Watch for unbounded growth.
- A blocking call inside
handle_call/3blocks the whole process. Spawn or delegate when work is slow.