Choosing Between GenServer.call and GenServer.cast for State Management
Learn when to use GenServer.call vs GenServer.cast in Elixir. We explore the trade-offs between synchronous consistency and asynchronous throughput with a practical rate-limiter example.
04 Jun 2026, 08:29 UTC

The Cost of Waiting for a Response
When building stateful services in Elixir, you often face a critical decision: do you need to know that an operation succeeded immediately, or can you fire a message and move on? This is the fundamental trade-off between GenServer.call/3 and GenServer.cast/2.
Choosing the wrong one doesn't just affect performance; it can introduce systemic instability. A synchronous call that hangs can block an entire request pipeline, while an asynchronous cast that overflows a mailbox can crash a node via memory exhaustion.
Synchronous Calls: Consistency at the Price of Latency
A call is a request-response pattern. The calling process stops execution and waits for the GenServer to process the message and return a value. This is essential when the next step of your business logic depends on the result of the state change.
- Use Case: Fetching a user's current balance or validating a token.
- Risk: If the GenServer becomes a bottleneck, calling processes will pile up, potentially hitting the default 5-second timeout and triggering a cascade of failures.
Asynchronous Casts: Throughput Without Guarantees
A cast is a "fire and forget" operation. The caller sends the message to the GenServer's mailbox and immediately resumes its own work. The GenServer processes these messages sequentially, but the caller never receives a confirmation.
- Use Case: Updating a non-critical cache, logging telemetry, or triggering a background cleanup task.
- Risk: Mailbox overflow. Because the caller doesn't wait, it can send messages faster than the GenServer can process them, leading to increased memory usage and eventual process collapse.
Worked Example: A Simple Rate Limiter
Consider a rate limiter that tracks request counts. We use call to check if a request is allowed (since we need the answer to proceed) and cast to increment the counter (since the exact millisecond the counter updates is less critical than the speed of the request).
defmodule RateLimiter do
use GenServer
# Client API
def start_link(initial_limit), do: GenServer.start_link(__MODULE__, initial_limit, name: __MODULE__)
def check_limit(user_id) do
# Synchronous: We MUST know if the user is blocked
GenServer.call(__MODULE__, {:check, user_id})
end
def increment_count(user_id) do
# Asynchronous: We don't need to wait for the update to finish
GenServer.cast(__MODULE__, {:increment, user_id})
end
# Server Callbacks
def init(limit), do: {:ok, %{limit: limit, counts: %{}}}
def handle_call({:check, user_id}, _from, %{limit: limit, counts: counts} = state) do
current_count = Map.get(counts, user_id, 0)
{:reply, current_count < limit, state}
end
def handle_cast({:increment, user_id}, state) do
new_counts = Map.update(state.counts, user_id, 1, &(& + 1))
{:noreply, %{state | counts: new_counts}}
end
end
Verification and Testing
To verify the behavior, run the following in an iex session. Note that increment_count returns :ok immediately, regardless of when the server actually processes the update:
# Start the server
RateLimiter.start_link(5)
# This blocks until the server replies
RateLimiter.check_limit(:user_1) # Expected: true
# This returns :ok immediately
RateLimiter.increment_count(:user_1) # Expected: :ok
The Single Point of Contention
Regardless of whether you use call or cast, a single GenServer processes messages sequentially. If your application has thousands of concurrent users all hitting one GenServer, that process becomes a bottleneck.
The Limitation: A single GenServer cannot scale across multiple CPU cores. If you observe high latency in call operations or a growing mailbox in cast operations, you must move toward a distributed state model, such as using a registry of multiple GenServers (sharding) or moving state to an external store like Redis.
Decision Summary
When deciding between the two, ask: "Does my current process need the result of this operation to decide what to do next?"
- Yes: Use
GenServer.call/3. Ensure you handle potential timeouts. - No: Use
GenServer.cast/2. Monitor your process mailbox size to prevent memory leaks.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.