Managing Application State with Elixir GenServer: Call vs. Cast
Learn how to implement state management in Elixir using GenServer, including the critical technical distinctions between synchronous calls and asynchronous casts.
15 Feb 2026, 11:09 UTC

The Problem: Coordinating State Across Concurrent Processes
In Elixir, data is immutable. You cannot simply update a global variable to track the state of a user session, a shopping cart, or a hardware connection. To maintain a changing state over time, you need a process that owns that state and handles updates sequentially. This is the primary role of a GenServer (Generic Server).
The challenge for engineers is choosing the correct communication pattern: synchronous calls for when you need a result, and asynchronous casts for when you only need to trigger an action. Choosing the wrong one can lead to system bottlenecks or silent failures.
Prerequisites
- Elixir 1.14+ installed.
- Basic familiarity with the Actor model (processes communicating via messages).
- A running project created via
mix new.
Implementing the GenServer Pattern
A GenServer typically consists of two modules: a Client API (public functions) and a Server (the process logic). This separation ensures the rest of your application doesn't need to know the internal structure of the state.
Consider a simple ConnectionTracker that keeps track of active client IDs.
defmodule ConnectionTracker.Server do
use GenServer
# Client API
def start_link(initial_state) do
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
end
def add_client(client_id) do
# Asynchronous: Fire and forget
GenServer.cast(__MODULE__, {:add, client_id})
end
def get_clients do
# Synchronous: Wait for response
GenServer.call(__MODULE__, :get_list)
end
# Server Callbacks
@impl true
def init(initial_state) do
{:ok, initial_state}
end
@impl true
def handle_call(:get_list, _from, state) do
# Returns: {:reply, response, new_state}
{:reply, state, state}
end
@impl true
def handle_cast({:add, client_id}, state) do
# Returns: {:noreply, new_state}
new_state = [client_id | state]
{:noreply, new_state}
end
end
Decision Matrix: Call vs. Cast
Choosing between GenServer.call/3 and GenServer.cast/2 depends on the requirements of the calling process.
| Feature | GenServer.call (Synchronous) |
GenServer.cast (Asynchronous) |
|---|---|---|
| Response | Returns a value to the caller. | Returns :ok immediately. |
| Blocking | Caller waits until the server replies. | Caller continues execution immediately. |
| Risk | Can cause timeouts if server is slow. | Mailbox overflow if cast rate > process rate. |
| Use Case | Fetching data, validating state. | Logging, updating counters, triggering side effects. |
Operational Risks and Diagnostics
Blocking the Client: If handle_call/3 performs a heavy computation or a slow network request, the calling process is frozen. If the server takes longer than 5 seconds (default), the caller will crash with a GenServer.Exit timeout error.
Mailbox Overflow: Because cast is asynchronous, the server processes messages as fast as it can. If you send 10,000 casts per second but the server only processes 1,000, the process mailbox will grow indefinitely, consuming memory until the BEAM VM crashes.
Verification and Testing
To verify the implementation, run the following in iex -S mix:
- Initialize: Run
ConnectionTracker.Server.start_link([]). Expected result:{:ok, pid}. - Test Cast: Run
ConnectionTracker.Server.add_client("user_1"). Expected result::ok. - Test Call: Run
ConnectionTracker.Server.get_clients(). Expected result:["user_1"].
Recovery and State Persistence
GenServers are volatile. If the process crashes, the state is lost. To prevent total system failure, always start your GenServer under a Supervisor. In your application.ex file, add the server to the children list:
children = [
{ConnectionTracker.Server, []}
]
Supervisor.start_link(children, strategy: :one_for_one)
Rollback/Reset: Since GenServers maintain state in memory, the only way to "rollback" to a clean state during development is to restart the process via the supervisor or by calling Process.exit(pid, :kill) if the supervisor is not active. In production, state persistence requires integrating a database or an ETS (Erlang Term Storage) table.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.