Choosing the Right GenServer Communication Pattern for Stateful Services
Learn how to choose between GenServer.call, cast, and handle_info to build responsive stateful services in Elixir without blocking your event loop.
14 Mar 2026, 00:59 UTC

The State Management Dilemma
When building stateful services in Elixir, the primary challenge is balancing data consistency with system responsiveness. Because a GenServer processes messages sequentially from its mailbox, a single long-running request can block all other operations, leading to timeouts and cascading failures across your supervision tree.
The core decision is whether to use synchronous calls, asynchronous casts, or asynchronous info messages based on whether the caller needs a response and how critical the operation's completion is to the current execution flow.
Comparison of Communication Patterns
| Pattern | Function | Blocking? | Return Value | Primary Use Case |
|---|---|---|---|---|
| Synchronous | GenServer.call/3 |
Yes | The result of the callback | Fetching state or requesting a calculation |
| Asynchronous | GenServer.cast/2 |
No | :ok (immediately) |
Fire-and-forget state updates |
| Info Message | send/2 |
No | :ok (immediately) |
External signals or timer events |
Engineering Trade-offs
Latency and the Mailbox
Using GenServer.call creates a tight coupling between the client and the server. If the server is busy processing a heavy task, the client process will sleep until the server responds or the call times out (default is 5 seconds). In high-throughput systems, this can lead to "mailbox congestion," where the queue grows faster than the server can process it.
Consistency vs. Availability
GenServer.cast improves availability by freeing the caller immediately, but it introduces a race condition. You cannot be certain when the state change actually occurs. If your logic requires a confirmation that a value was persisted before proceeding, cast is inappropriate.
Handling External Signals
handle_info/2 is the catch-all for messages that don't follow the call/cast protocol. This is essential for implementing timeouts using Process.send_after/3. Since these messages are processed in the same sequential queue, they are subject to the same blocking risks as synchronous calls.
Implementation: A Rate-Limited Cache
The following example implements a simple stateful cache that uses call for retrieval and cast for updates to ensure reads are consistent while writes don't block the application flow.
defmodule RateLimiter do
use GenServer
# Client API
def start_link(initial_limit), do: GenServer.start_link(__MODULE__, initial_limit, name: __MODULE__)
def get_limit, do: GenServer.call(__MODULE__, :get_limit)
def update_limit(new_limit), do: GenServer.cast(__MODULE__, {:set_limit, new_limit})
# Server Callbacks
@impl true
def init(initial_limit) do
# Initial state is a simple map
{:ok, %{limit: initial_limit}}
end
@impl true
def handle_call(:get_limit, _from, state) do
# Return the current limit and the unchanged state
{:reply, state.limit, state}
end
@impl true
def handle_cast({:set_limit, new_limit}, state) do
# Update state without blocking the caller
{:noreply, %{state | limit: new_limit}}
end
end
Execution and Verification
To verify this implementation, run the following in an iex session:
- Start the server:
RateLimiter.start_link(100). Expected result:{:ok, pid}. - Verify initial state:
RateLimiter.get_limit(). Expected result:100. - Update state asynchronously:
RateLimiter.update_limit(200). Expected result::ok. - Verify update:
RateLimiter.get_limit(). Expected result:200.
Limitations and Risk Mitigation
- Blocking the Loop: If a
handle_callperforms a heavy database query or external API request, the entire GenServer is frozen. Mitigation: Offload heavy work to a separate Task or a worker pool. - Memory Leaks: Large states stored in a GenServer can lead to increased Garbage Collection (GC) pressure. Mitigation: For very large datasets, use
:ets(Erlang Term Storage) and store only the table reference in the GenServer state. - Crash Propagation: An uncaught exception in a callback will crash the GenServer. If the supervisor is configured as
:one_for_one, only that process restarts, but any transient state not persisted to a database will be lost.
Rollback Procedure
Since this implementation changes the process state in memory, the only "rollback" is to restart the GenServer with the original initial state or call update_limit/1 with the previous value.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.