Diagnosing GenServer.call Timeouts and Mailbox Backpressure in Elixir
A diagnostic guide for Elixir GenServer synchronous timeouts, growing mailboxes, and cascading backpressure in supervised systems.
25 Apr 2026, 02:51 UTC

The Recognizable Condition
Your application starts returning {:exit, :timeout} from GenServer.call/3, or the call raises a timeout exception. Response latency climbs steadily rather than spiking once and recovering. Downstream callers block while waiting for replies, and the supervisor tree appears healthy even though the system is degrading. This pattern usually points to a GenServer whose mailbox is growing faster than it can drain, or a handler that is blocking the process loop.
Likely Causes and Diagnostic Signals
| Cause | Diagnostic Signal | What to Check First |
|---|---|---|
| Slow handler blocks the process loop | Latency scales with handler duration, not caller count | handle_call clauses doing CPU-bound work, synchronous HTTP or database calls, or long computations |
| Message flood from many callers | Mailbox length grows under load while the handler itself is fast | Caller throughput metrics versus queue depth; Process.info(pid, :message_queue_len) |
Unhandled :info messages accumulate | Mailbox grows even when no matching calls are arriving | Missing handle_info clauses or absent catch-all for expected messages |
| Linked process crashes cause repeated restarts | Mailbox resets, state is lost, and timeouts recur after each restart | Supervisor logs, linked process crash reports, and restart frequency |
Ordered Checks
Check 1: Inspect mailbox length and process status
From an IEx session attached to the node, or from a test, identify the process and inspect its mailbox:
pid = GenServer.whereis(MyApp.Worker)
Process.info(pid, [:message_queue_len, :status, :current_function])
Expected result: :message_queue_len is above zero and climbing under load. Treat this as a snapshot, not a sustained rate; sample repeatedly before concluding there is a leak.
Check 2: Correlate timeouts with caller throughput
Compare application metrics or log timestamps for caller request rate against GenServer.call timeout events. If caller throughput is flat but queue depth rises, the handler is the bottleneck. If throughput spikes and queue depth rises in parallel, the issue is inbound load rather than handler speed.
Check 3: Audit the callback clauses
Review handle_call/3, handle_cast/2, and handle_info/2 for blocking work, long-running I/O, or recursive sends back to the same server. Add temporary logging around the handler body to measure duration independently of caller wait time:
def handle_call(:expensive, _from, state) do
start = System.monotonic_time(:millisecond)
result = do_expensive_work(state)
duration = System.monotonic_time(:millisecond) - start
Logger.debug("Handler took #{duration}ms")
{:reply, result, state}
end
If handler duration approaches the GenServer.call timeout (5,000 ms by default), the caller will exit before the reply is ready.
Fixes Tied to Findings
If the handler is slow
Move blocking work to Task.async_stream/3 or a dedicated worker pool so the GenServer only coordinates state and replies quickly. The GenServer should not perform long-running I/O or CPU-bound work inside its callback; it should delegate and await the result with a short, bounded timeout.
If the mailbox is flooded
Add selective message handling, rate limiting, or convert synchronous operations to GenServer.cast for fire-and-forget paths. When ordering matters, use selective receive in the caller or batch requests into a single call rather than issuing one call per item.
If unhandled messages accumulate
Ensure every expected message type has a handle_info clause, or add a catch-all that logs and discards unexpected messages to prevent unbounded growth. A missing clause for a periodic tick or monitor message will silently fill the mailbox.
Escalation Criteria
If timeouts persist after offloading work and queue length stays above hundreds during normal load, a single GenServer boundary is likely a capacity limit. Redesign the boundary as multiple partitioned GenServers—for example, sharding by key—or involve the platform team for a capacity review. Do not simply kill or restart the process: restarts reset state and can amplify cascading failures in supervisors.
Limitations and Verification
This guide applies to stable OTP releases and Elixir 1.10+. Behavior is version-stable across OTP 21+, but exact telemetry event names depend on your application, not the language. To verify a fix, sample Process.info(pid, :message_queue_len) repeatedly under load and confirm it returns to a steady low value. You can also start :observer, open the Processes table, sort by reductions or memory, and inspect the suspect GenServer's message_queue_len and status fields.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.