Skip to main content
Technology & EngineeringElixir Phoenix165 lines

Genserver

GenServer patterns for stateful processes in Elixir OTP applications

Quick Summary18 lines
You are an expert in GenServer design for building robust stateful Elixir applications.

## Key Points

- **Client API**: Public functions that send messages to the server process.
- **Server Callbacks**: `init/1`, `handle_call/3`, `handle_cast/2`, `handle_info/2`, `terminate/2`.
- **State Management**: The server process holds state that is passed through each callback and returned (possibly modified) in the reply tuple.
- **Naming**: Processes can be registered with a name (atom, `{:via, module, term}`, or `{:global, term}`).
- Always define a client API that wraps `GenServer.call/2` and `GenServer.cast/2` — callers should never construct messages directly.
- Use `@impl true` on all callback functions to get compile-time warnings for typos.
- Keep `handle_call` and `handle_cast` callbacks small; delegate heavy work to private functions.
- Use structs or maps with well-defined keys for state rather than bare tuples.
- Set timeouts or use `handle_continue/2` for expensive initialization to avoid blocking the supervisor.
- Prefer `call` over `cast` unless you explicitly need fire-and-forget semantics — `call` provides back-pressure and error propagation.
- **Bottleneck single process**: A single GenServer serializes all requests. If throughput matters, partition state across multiple processes or use ETS for reads.
- **Storing too much state**: Large state in a GenServer increases garbage collection pressure. Offload large data to ETS or persistent storage.
skilldb get elixir-phoenix-skills/GenserverFull skill: 165 lines
Paste into your CLAUDE.md or agent config

GenServer Patterns — Elixir/Phoenix

You are an expert in GenServer design for building robust stateful Elixir applications.

Overview

GenServer is a behaviour module in OTP that abstracts the common client-server interaction pattern. It provides a process that keeps state, handles synchronous (call) and asynchronous (cast) requests, and integrates with OTP supervision trees.

Core Philosophy

A GenServer is a process that manages state and serializes access to it. This is a powerful primitive, but it is also a bottleneck by design — every call and cast for a given GenServer is processed sequentially. Understanding this tradeoff is essential: GenServers provide safety through serialization, but they become performance constraints if every operation in your system funnels through a single process.

The client-server separation in GenServer is not just an implementation detail; it is a design principle. The client API (public functions) defines what the outside world can do. The server callbacks (handle_call, handle_cast, handle_info) define how the process responds internally. Callers should never construct messages directly or know about the internal state shape. This boundary makes it possible to refactor the server's internals without affecting any calling code.

GenServers should be thin coordination layers, not repositories for business logic. A GenServer that directly queries databases, calls HTTP APIs, and transforms data in its callbacks becomes untestable and fragile. Instead, delegate the actual work to pure functions or context modules, and let the GenServer focus on what it does uniquely well: holding state and serializing access to it.

Anti-Patterns

  • The Singleton Bottleneck: Routing all application requests through a single named GenServer. Since a GenServer processes one message at a time, this serializes your entire application. If throughput matters, partition state across multiple processes or use ETS for read-heavy workloads.

  • Blocking Callbacks: Performing HTTP requests, slow database queries, or file I/O inside handle_call/3. This blocks all other callers waiting for a response. Spawn a Task for slow work and either reply immediately with an acknowledgment or use handle_continue/2 for deferred initialization.

  • Unbounded State Growth: Accumulating data in GenServer state without bounds — storing every event, caching every result, appending to a list indefinitely. This increases garbage collection pressure and memory consumption over time. Set size limits, periodically flush to persistent storage, or use ETS for large datasets.

  • Missing Catch-All handle_info: Omitting a catch-all clause in handle_info/2. Unexpected messages (from monitors, linked processes, or stale timers) silently accumulate in the mailbox, consuming memory. Always log and discard unrecognized messages.

  • Direct Message Construction: Having callers send messages directly with send/2 or GenServer.call(pid, {:some, :internal, :message}) instead of going through the client API. This couples callers to the server's internal message format and breaks encapsulation.

Core Concepts

  • Client API: Public functions that send messages to the server process.
  • Server Callbacks: init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2.
  • State Management: The server process holds state that is passed through each callback and returned (possibly modified) in the reply tuple.
  • Naming: Processes can be registered with a name (atom, {:via, module, term}, or {:global, term}).

Implementation Patterns

Basic GenServer

defmodule MyApp.Counter do
  use GenServer

  # Client API

  def start_link(initial_value \\ 0) do
    GenServer.start_link(__MODULE__, initial_value, name: __MODULE__)
  end

  def increment do
    GenServer.call(__MODULE__, :increment)
  end

  def get_value do
    GenServer.call(__MODULE__, :get_value)
  end

  # Server Callbacks

  @impl true
  def init(initial_value) do
    {:ok, initial_value}
  end

  @impl true
  def handle_call(:increment, _from, state) do
    new_state = state + 1
    {:reply, new_state, new_state}
  end

  @impl true
  def handle_call(:get_value, _from, state) do
    {:reply, state, state}
  end
end

GenServer with Periodic Work

defmodule MyApp.Poller do
  use GenServer

  @interval :timer.seconds(30)

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(opts) do
    schedule_poll()
    {:ok, %{results: [], endpoint: Keyword.fetch!(opts, :endpoint)}}
  end

  @impl true
  def handle_info(:poll, state) do
    results = fetch_data(state.endpoint)
    schedule_poll()
    {:noreply, %{state | results: results}}
  end

  defp schedule_poll do
    Process.send_after(self(), :poll, @interval)
  end

  defp fetch_data(endpoint) do
    # HTTP call or other work
    []
  end
end

Dynamic Named Processes with Registry

defmodule MyApp.SessionServer do
  use GenServer

  def start_link(session_id) do
    GenServer.start_link(__MODULE__, session_id, name: via_tuple(session_id))
  end

  def get_data(session_id) do
    GenServer.call(via_tuple(session_id), :get_data)
  end

  defp via_tuple(session_id) do
    {:via, Registry, {MyApp.SessionRegistry, session_id}}
  end

  @impl true
  def init(session_id) do
    {:ok, %{session_id: session_id, data: %{}}}
  end

  @impl true
  def handle_call(:get_data, _from, state) do
    {:reply, state.data, state}
  end
end

Best Practices

  • Always define a client API that wraps GenServer.call/2 and GenServer.cast/2 — callers should never construct messages directly.
  • Use @impl true on all callback functions to get compile-time warnings for typos.
  • Keep handle_call and handle_cast callbacks small; delegate heavy work to private functions.
  • Use structs or maps with well-defined keys for state rather than bare tuples.
  • Set timeouts or use handle_continue/2 for expensive initialization to avoid blocking the supervisor.
  • Prefer call over cast unless you explicitly need fire-and-forget semantics — call provides back-pressure and error propagation.

Common Pitfalls

  • Bottleneck single process: A single GenServer serializes all requests. If throughput matters, partition state across multiple processes or use ETS for reads.
  • Storing too much state: Large state in a GenServer increases garbage collection pressure. Offload large data to ETS or persistent storage.
  • Blocking in callbacks: Long-running work inside handle_call blocks all other callers. Spawn a task or use handle_continue instead.
  • Missing handle_info clause: Unhandled messages silently accumulate in the mailbox. Always add a catch-all handle_info that logs unexpected messages.
  • Not handling :timeout in init: If init/1 takes too long, the supervisor may consider the start failed. Use {:ok, state, {:continue, :setup}} for deferred initialization.

Install this skill directly: skilldb add elixir-phoenix-skills

Get CLI access →