Diagnosing Intermittent GenServer.call Timeouts in Elixir
A diagnostic guide for intermittent GenServer.call timeouts in Elixir, covering caller timeouts, mailbox backlog, blocked callbacks, and escalation criteria.
25 Jun 2026, 02:33 UTC

The problem and what to check first
A synchronous GenServer.call/3 returns {:timeout, {GenServer, :call, [pid, message, timeout]}} instead of the server's reply. The caller exits, but the server process may still be alive and blocked inside its callback. Restarting the server or raising the timeout without diagnosing the cause usually moves the problem elsewhere or hides it under load.
Useful takeaway: treat the timeout as a caller-side exit signal, not proof that the GenServer crashed. Before you change anything, confirm whether the target process is alive, blocked in a callback, or buried behind a growing mailbox.
Recognizable condition
You will see this pattern in logs or stacktraces:
- An exit reason shaped like
{:timeout, {GenServer, :call, [pid, request, milliseconds]}}. - The failure appears intermittently under load, immediately after a deploy, or when a downstream database or HTTP dependency stalls.
- The default timeout is 5000 milliseconds when the caller omits it, though libraries or supervision configuration can override that value.
Cause and diagnostic table
| Observation | Likely cause | Check |
|---|---|---|
| Caller timeout is lower than observed server latency | Timeout too low for the workload | Compare the configured timeout with p99 latency and the caller stacktrace. |
| Messages pile up ahead of the call | Mailbox backlog | Process.info(pid, :message_queue_len) |
handle_call/3 does not return | Server blocked in a callback | Process.info(pid, [:current_function, :status]) |
| Stacktrace shows another synchronous call | Nested GenServer.call or lock contention | Inspect the caller stacktrace for GenServer.call, ETS, or :persistent_term |
| High time around database or HTTP calls | Slow downstream I/O | Add timing around the dependency and check its own latency metrics |
| High reductions or memory growth | GC or scheduler pressure | Process.info(pid, [:memory, :reductions]) and :erlang.system_info(:schedulers_online) |
| The registered name points to a new PID over time | Restart loop | Process.whereis/1 across samples and Supervisor.which_children/1 |
Ordered diagnostic checks
- Capture the call site and timeout. Find the
GenServer.call/3invocation and note whether a timeout argument was supplied. Run this in your local Mix project or production console where the error reproduces. - Verify the target process. Run
pid = Process.whereis(MyApp.ServerName)iniex -S mixor a remote production console. You need read access to the running node. If it returnsnil, the server is not registered or has restarted; checkSupervisor.which_children/1next. - Sample process internals. Run
Process.info(pid, [:message_queue_len, :current_function, :status, :memory, :reductions]). A growing:message_queue_lenmeans callers are arriving faster than the server replies. A:current_functioninsidehandle_callmeans the server is blocked in a callback. - Inspect state without hanging the shell. Do not call
:sys.get_state/1or:sys.get_status/1directly from an IEx session attached to a blocked process. Run them inside aTaskwith a timeout so the diagnostic cannot hang your shell:
Task.async(fn -> :sys.get_state(pid) end)
|> Task.then(fn result ->
case result do
{:ok, state} -> IO.inspect(state, label: "server state")
:exit -> IO.puts("diagnostic timed out")
end
end, timeout: 2000)
Replace pid with the value from Process.whereis/1. If the task returns :exit, the server is too blocked to answer state requests.
- Trace briefly and only if needed. Run
:erlang.trace(pid, true, [:call, :receive])for a short window, then stop it with:erlang.trace(pid, false). Tracing a hot production process adds overhead and can expose sensitive payloads in trace output. Obtain operational permission before tracing production. - Compare against a baseline. Capture mailbox length, reductions, memory, and call latency for the same PID before and after load. A deviation from baseline is stronger evidence than any single snapshot.
Fixes tied to findings
- Timeout too low: Raise the caller timeout only after confirming the server's p99 latency. Example: change
GenServer.call(pid, msg, timeout: 5000)toGenServer.call(pid, msg, timeout: 10_000). If you change a config default, rollback means redeploying the previous config version. - Mailbox backlog: Add batching, rate limiting, or a worker pool so the GenServer is not the single serialization point for every caller.
- Blocked callback: Move long work out of
handle_call/3into a supervisedTask, then callGenServer.reply/2from the task when the result is ready. The callback returns immediately, so callers do not time out. - Nested synchronous calls: Replace inner
GenServer.callwith asynchronous messages orGenServer.cast/2where a reply is not required. - Large messages or idle memory: Reduce binaries passed in calls and return
:hibernatefrom idle callbacks to shrink the process heap.
Escalation criteria
- One PID stays blocked with a growing mailbox and
:sys.get_status/1shows a long-running callback. - Timeouts span nodes or point to a shared dependency rather than one GenServer.
- The fix changes a supervision strategy or a global timeout default.
- Production tracing or crashdump collection is required to reproduce the issue.
Limitations and verification
This guide assumes a standard OTP GenServer using handle_call/3. The default GenServer.call/3 timeout is 5000 milliseconds when omitted, but libraries and supervision trees may override it. Process.info/2 fields can vary by OTP release, so run the checks on your target release before using them in production.
Practical check: reproduce locally with a GenServer that sleeps longer than the caller timeout inside handle_call/3, then assert the exit reason matches {:timeout, {GenServer, :call, [pid, msg, timeout]}}. In iex -S mix, start a named GenServer, block it with :timer.sleep/1 in handle_call, call it, and inspect Process.info(pid, [:message_queue_len, :current_function]). Do not claim the production behavior matches until you observe it on the target system.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.