Skip to content
AyoKoding

Genserver Patterns

Building stateful systems in Elixir? This guide teaches GenServer patterns through the OTP-First progression, starting with manual process primitives to understand state management challenges before introducing GenServer abstractions.

Why GenServer Matters

Most production systems need stateful components:

  • Web servers - Session storage, connection pools, cache management
  • Background workers - Job queues, rate limiters, metrics collectors
  • Real-time systems - Live data feeds, notification dispatchers, game servers
  • Domain logic - Contract state machines, workflow engines, business processes

Elixir provides two approaches:

  1. Manual processes - spawn_link, send, receive primitives (maximum control)
  2. GenServer behavior - OTP-compliant generic server (production standard)

Our approach: Build with manual processes first to understand state management challenges, then see how GenServer solves them systematically.

OTP Primitives - Manual State Management

Basic Manual Process

Let's build a counter using raw process primitives:

# Manual counter using spawn_link and receive
defmodule ManualCounter do
  # => Client API
  def start_link(initial \\ 0) do
    pid = spawn_link(__MODULE__, :init, [initial])
                                             # => Spawns linked process
                                             # => initial: Starting count
                                             # => Returns: pid
    {:ok, pid}                               # => Wraps in OTP-style tuple
  end
 
  def increment(pid) do
    send(pid, {:increment, self()})          # => Sends message to process
                                             # => self(): Reply-to address
    receive do
      {:reply, value} -> value               # => Waits for response
                                             # => Returns: new value
    after
      5000 -> {:error, :timeout}             # => 5 second timeout
    end
  end
 
  def get(pid) do
    send(pid, {:get, self()})                # => Request current value
    receive do
      {:reply, value} -> value               # => Returns: current value
    after
      5000 -> {:error, :timeout}
    end
  end
 
  # => Server implementation
  def init(initial) do
    loop(initial)                            # => Enters message loop
                                             # => initial: Starting state
  end
 
  defp loop(state) do
    receive do
      {:increment, caller} ->
        new_state = state + 1                # => Increment counter
        send(caller, {:reply, new_state})    # => Send response
        loop(new_state)                      # => Recurse with new state
                                             # => Tail call optimized
 
      {:get, caller} ->
        send(caller, {:reply, state})        # => Send current value
        loop(state)                          # => Recurse with same state
    end
  end
end

Usage:

{:ok, pid} = ManualCounter.start_link(0)     # => Start counter at 0
                                             # => pid: Process identifier
 
ManualCounter.increment(pid)                 # => Returns: 1
ManualCounter.increment(pid)                 # => Returns: 2
ManualCounter.get(pid)                       # => Returns: 2

Limitations of Manual Processes

This manual approach has serious production issues:

1. No OTP Compliance

# Manual process doesn't follow OTP conventions
{:ok, pid} = ManualCounter.start_link(0)
# => Returns {:ok, pid}
# => But supervisor expects specific startup protocol
# => Missing: System messages handling
# => Missing: Debug info support
# => Missing: Hot code upgrade support

2. Message Handling Boilerplate

# Every operation needs:
# 1. Send message
# 2. Receive response
# 3. Handle timeout
# 4. Pattern match reply
 
# Verbose and error-prone
def get_multiple(pid, count) do
  Enum.map(1..count, fn _ ->
    send(pid, {:get, self()})
    receive do
      {:reply, value} -> value
    after
      5000 -> {:error, :timeout}
    end
  end)
end
# => Repetitive timeout logic
# => No shared infrastructure

3. No State Lifecycle Management

# Missing lifecycle hooks:
# - Initialization validation
# - Cleanup on termination
# - State persistence
# - Graceful shutdown
 
defp loop(state) do
  receive do
    {:stop, caller} ->
      send(caller, {:reply, :ok})
      # => Process exits
      # => No cleanup callback
      # => No resource release
      # => State lost immediately
  end
end

4. Complex Synchronous Operations

# Implementing call/cast distinction manually:
defp loop(state) do
  receive do
    {:call, from, msg} ->
      # => Synchronous: Must reply
      {reply, new_state} = handle_call(msg, state)
      send(from, {:reply, reply})
      loop(new_state)
 
    {:cast, msg} ->
      # => Asynchronous: No reply
      new_state = handle_cast(msg, state)
      loop(new_state)
      # => Duplicates GenServer logic
  end
end

5. No Built-in Timeout Handling

# Server-side timeouts require manual tracking:
defp loop(state) do
  receive do
    {:hibernate, caller} ->
      send(caller, {:reply, :ok})
      # => Want to hibernate after inactivity
      # => No built-in mechanism
      # => Must implement timer logic
  after
    60_000 ->
      # => After 60s of inactivity
      # => Manual hibernate logic
      :erlang.hibernate(__MODULE__, :loop, [state])
  end
end

Production Disaster Scenarios

Scenario 1: Process Leak

# Starting 1000 counters without supervision
pids = Enum.map(1..1000, fn i ->
  {:ok, pid} = ManualCounter.start_link(i)
  pid
end)
# => 1000 processes created
# => No supervision tree
# => If one crashes, no restart
# => If parent crashes, all orphaned
# => Memory leak potential

Scenario 2: Message Queue Overflow

# Rapid message sending without backpressure
{:ok, pid} = ManualCounter.start_link(0)
 
Enum.each(1..1_000_000, fn _ ->
  send(pid, {:increment, self()})            # => Fire and forget
                                             # => Message queue grows
                                             # => No flow control
end)
# => Process mailbox fills up
# => Memory exhaustion
# => System crash

Scenario 3: Graceless Shutdown

# Process exits without cleanup
defmodule DatabaseConnection do
  def loop(conn) do
    receive do
      {:query, caller, sql} ->
        result = :db.query(conn, sql)        # => External resource
        send(caller, {:reply, result})
        loop(conn)
    end
  end
end
# => Process killed by supervisor
# => Connection never closed
# => Database connection leak

GenServer - Production State Management

Basic GenServer Counter

GenServer provides a battle-tested abstraction for stateful processes:

# Production-ready counter with GenServer
defmodule Counter do
  use GenServer                              # => Imports GenServer behavior
                                             # => Provides: init, handle_call, etc.
 
  # => Client API (runs in caller's process)
  def start_link(initial \\ 0) do
    GenServer.start_link(__MODULE__, initial, name: __MODULE__)
                                             # => Starts GenServer process
                                             # => __MODULE__: Callback module
                                             # => initial: Init argument
                                             # => name: Registered process name
                                             # => Returns: {:ok, pid} or {:error, reason}
  end
 
  def increment do
    GenServer.call(__MODULE__, :increment)   # => Synchronous call
                                             # => Waits for reply
                                             # => Default timeout: 5000ms
                                             # => Returns: new value
  end
 
  def get do
    GenServer.call(__MODULE__, :get)         # => Synchronous call
                                             # => Returns: current value
  end
 
  # => Server callbacks (runs in GenServer process)
  @impl true
  def init(initial) do
    {:ok, initial}                           # => Initial state: initial value
                                             # => Returns: {:ok, state}
  end
 
  @impl true
  def handle_call(:increment, _from, state) do
    new_state = state + 1                    # => Increment counter
    {:reply, new_state, new_state}           # => Reply with new value
                                             # => Update state to new_state
                                             # => Type: {:reply, reply, new_state}
  end
 
  @impl true
  def handle_call(:get, _from, state) do
    {:reply, state, state}                   # => Reply with current value
                                             # => State unchanged
  end
end

Usage:

{:ok, _pid} = Counter.start_link(0)          # => Start counter, registered name
                                             # => Can call by name, not pid
 
Counter.increment()                          # => Returns: 1
Counter.increment()                          # => Returns: 2
Counter.get()                                # => Returns: 2

GenServer Benefits Over Manual Processes

1. OTP Compliance

GenServer automatically handles:

  • Supervisor startup protocol
  • System message handling (suspend, resume, code change)
  • Debug information (sys module integration)
  • Hot code upgrade support

2. Simplified API

# Manual process (verbose)
send(pid, {:get, self()})                    # => Send message to process
                                             # => pid: Target process identifier
                                             # => {:get, self()}: Message with return address
receive do                                   # => Block and wait for response
                                             # => Pattern match incoming messages
  {:reply, value} -> value                   # => Match reply tuple
                                             # => Extract and return value
after                                        # => Timeout clause
  5000 -> {:error, :timeout}                 # => 5 second maximum wait
                                             # => Returns error if no reply
end                                          # => Type: value | {:error, :timeout}
                                             # => 9 lines of boilerplate per operation
 
# GenServer (concise)
GenServer.call(pid, :get)                    # => Single line replaces 9 lines above
                                             # => All boilerplate hidden
                                             # => Timeout handled automatically
                                             # => Type-safe reply guaranteed
                                             # => Default 5s timeout

3. Built-in Lifecycle Hooks

@impl true                                   # => Marks GenServer callback implementation
                                             # => Compiler verifies function signature
def init(initial) do                         # => Called when GenServer starts
                                             # => initial: Argument from start_link
                                             # => Runs in GenServer process
  # => Initialization logic                 # => Validate initial state
  # => Validate state                       # => Setup external resources (connections, files)
  # => Setup resources                      # => Register names or subscriptions
  {:ok, initial}                             # => Success tuple
                                             # => initial: Becomes process state
                                             # => Type: {:ok, state} | {:stop, reason}
end
 
@impl true                                   # => Terminate callback implementation
                                             # => Called on graceful shutdown
def terminate(reason, state) do              # => reason: Why process is stopping
                                             # => state: Current process state
                                             # => Runs before process exits
  # => Cleanup on shutdown                  # => Close database connections
  # => Release resources                    # => Cancel timers or subscriptions
  # => Persist state                        # => Save state to disk/database
  :ok                                        # => Return value ignored
                                             # => Process exits after this function
end
 
**4. Clear Synchronous/Asynchronous Distinction**
 
```elixir
# Synchronous: handle_call (waits for reply)
@impl true                                   # => GenServer callback implementation
                                             # => Required for handle_call pattern
def handle_call(:get, _from, state) do       # => :get: Message pattern to match
                                             # => _from: Caller PID (unused here)
                                             # => state: Current process state
                                             # => Runs in GenServer process
  {:reply, state, state}                     # => Tuple: {:reply, reply_value, new_state}
                                             # => First state: Value sent to caller
                                             # => Second state: Updated process state
                                             # => Caller blocks until reply
                                             # => Type: {:reply, term(), term()}
end
 
# Asynchronous: handle_cast (fire and forget)
@impl true                                   # => Callback for async messages
                                             # => No reply expected
def handle_cast(:reset, _state) do           # => :reset: Message pattern
                                             # => _state: Current state (ignored)
                                             # => Runs in GenServer process
  {:noreply, 0}                              # => Tuple: {:noreply, new_state}
                                             # => No reply sent
                                             # => 0: Reset state to zero
                                             # => Caller continues immediately
                                             # => Type: {:noreply, term()}
end

5. Built-in Timeout Support

# Server-side timeouts
@impl true                                   # => GenServer callback
                                             # => Handle synchronous call
def handle_call(:long_operation, _from, state) do
                                             # => :long_operation: Message pattern
                                             # => _from: Caller PID (unused)
                                             # => state: Current GenServer state
  result = expensive_computation()           # => Expensive blocking operation
                                             # => result: Computation result
                                             # => Type depends on computation
  {:reply, result, state, 10_000}            # => Four-element reply tuple
                                             # => result: Value sent to caller
                                             # => state: Process state unchanged
                                             # => 10_000: Hibernate after 10s idle
                                             # => Reduces memory if no messages
                                             # => Type: {:reply, term(), term(), timeout()}
end
 
# Client-side timeouts
GenServer.call(pid, :get, 1000)              # => Synchronous call with custom timeout
                                             # => pid: Target GenServer process
                                             # => :get: Message to send
                                             # => 1000: Wait maximum 1 second
                                             # => Raises if timeout exceeded
                                             # => Default timeout is 5000ms
                                             # => Type: term() (or raises)

Production Patterns

Pattern 1: Financial Contract State Machine

Managing Murabaha contract state (Sharia-compliant financing):

# Murabaha contract state management
defmodule MurabahaContract do
  use GenServer                              # => GenServer behavior
 
  # => Contract states:
  # => :pending -> :approved -> :disbursed -> :repaying -> :completed
  # => :pending -> :rejected
 
  defstruct [
    :contract_id,                            # => UUID
    :customer_id,                            # => Customer reference
    :asset_cost,                             # => Original asset cost
    :profit_amount,                          # => Profit (markup)
    :total_amount,                           # => Total owed
    :state,                                  # => Current state
    :approved_at,                            # => Approval timestamp
    :disbursed_at,                           # => Disbursement timestamp
    :payments                                # => List of payments
  ]
 
  # => Client API
  def start_link(contract_id, customer_id, asset_cost, profit_amount) do
    initial = %__MODULE__{
      contract_id: contract_id,
      customer_id: customer_id,
      asset_cost: asset_cost,
      profit_amount: profit_amount,
      total_amount: asset_cost + profit_amount,
      state: :pending,
      payments: []
    }
    GenServer.start_link(__MODULE__, initial, name: via_tuple(contract_id))
                                             # => Registers via Registry
                                             # => Each contract = separate process
  end
 
  def approve(contract_id) do
    GenServer.call(via_tuple(contract_id), :approve)
                                             # => Synchronous state transition
                                             # => Returns: {:ok, contract} or {:error, reason}
  end
 
  def disburse(contract_id) do
    GenServer.call(via_tuple(contract_id), :disburse)
                                             # => Disburse funds to customer
  end
 
  def record_payment(contract_id, amount) do
    GenServer.call(via_tuple(contract_id), {:record_payment, amount})
                                             # => Record payment, update state
  end
 
  def get_state(contract_id) do
    GenServer.call(via_tuple(contract_id), :get_state)
                                             # => Returns: current contract state
  end
 
  # => Server callbacks
  @impl true
  def init(contract) do
    # => Optional: Persist initial state to database
    {:ok, contract}                          # => Initial state: pending contract
  end
 
  @impl true
  def handle_call(:approve, _from, %{state: :pending} = contract) do
    new_contract = %{contract |
      state: :approved,
      approved_at: DateTime.utc_now()
    }
    # => TODO: Persist to database
    {:reply, {:ok, new_contract}, new_contract}
                                             # => State transition: pending -> approved
  end
 
  @impl true
  def handle_call(:approve, _from, contract) do
    {:reply, {:error, :invalid_state}, contract}
                                             # => Can only approve pending contracts
                                             # => State unchanged
  end
 
  @impl true
  def handle_call(:disburse, _from, %{state: :approved} = contract) do
    # => Disburse funds (call external payment system)
    case disburse_funds(contract) do
      :ok ->
        new_contract = %{contract |
          state: :disbursed,
          disbursed_at: DateTime.utc_now()
        }
        {:reply, {:ok, new_contract}, new_contract}
                                             # => State transition: approved -> disbursed
 
      {:error, reason} ->
        {:reply, {:error, reason}, contract}
                                             # => Disbursement failed, state unchanged
    end
  end
 
  @impl true
  def handle_call(:disburse, _from, contract) do
    {:reply, {:error, :invalid_state}, contract}
                                             # => Can only disburse approved contracts
  end
 
  @impl true
  def handle_call({:record_payment, amount}, _from, %{state: state} = contract)
      when state in [:disbursed, :repaying] do
    new_payments = [%{amount: amount, timestamp: DateTime.utc_now()} | contract.payments]
                                             # => Add payment to list
    total_paid = Enum.sum(Enum.map(new_payments, & &1.amount))
                                             # => Calculate total paid
 
    new_state = if total_paid >= contract.total_amount do
      :completed                             # => Fully paid
    else
      :repaying                              # => Partial payment
    end
 
    new_contract = %{contract |
      state: new_state,
      payments: new_payments
    }
 
    {:reply, {:ok, new_contract}, new_contract}
                                             # => Reply with updated contract
                                             # => Update state
  end
 
  @impl true
  def handle_call({:record_payment, _amount}, _from, contract) do
    {:reply, {:error, :invalid_state}, contract}
                                             # => Can only record payment for disbursed/repaying
  end
 
  @impl true
  def handle_call(:get_state, _from, contract) do
    {:reply, contract, contract}             # => Return current state
                                             # => State unchanged
  end
 
  @impl true
  def terminate(reason, contract) do
    # => Cleanup on shutdown
    IO.puts("Contract #{contract.contract_id} terminating: #{inspect(reason)}")
    # => TODO: Persist final state to database
    :ok
  end
 
  # => Helper functions
  defp via_tuple(contract_id) do
    {:via, Registry, {MurabahaRegistry, contract_id}}
                                             # => Named process via Registry
                                             # => Enables lookup by contract_id
  end
 
  defp disburse_funds(_contract) do
    # => TODO: Call external payment system
    :ok
  end
end

Usage:

# Setup Registry
{:ok, _} = Registry.start_link(keys: :unique, name: MurabahaRegistry)
 
# Create contract
{:ok, _pid} = MurabahaContract.start_link(
  "contract-123",                            # => contract_id
  "customer-456",                            # => customer_id
  100_000,                                   # => asset_cost (100k)
  10_000                                     # => profit_amount (10k markup)
)
# => Contract created in :pending state
 
# Approve contract
{:ok, contract} = MurabahaContract.approve("contract-123")
# => State: :pending -> :approved
# => contract.state: :approved
# => contract.approved_at: timestamp
 
# Disburse funds
{:ok, contract} = MurabahaContract.disburse("contract-123")
# => State: :approved -> :disbursed
# => Funds transferred to customer
 
# Record payments
{:ok, contract} = MurabahaContract.record_payment("contract-123", 50_000)
# => State: :disbursed -> :repaying
# => Payment recorded: 50k
 
{:ok, contract} = MurabahaContract.record_payment("contract-123", 60_000)
# => State: :repaying -> :completed
# => Total paid: 110k (>= 110k required)
# => Contract completed

Pattern 2: Asynchronous Operations with handle_cast

When reply not needed, use handle_cast for fire-and-forget operations:

defmodule NotificationQueue do
  use GenServer
 
  # => Client API
  def start_link(_opts) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end
 
  def enqueue(notification) do
    GenServer.cast(__MODULE__, {:enqueue, notification})
                                             # => Asynchronous, no reply
                                             # => Returns: :ok immediately
  end
 
  def get_queue do
    GenServer.call(__MODULE__, :get_queue)   # => Synchronous, waits for reply
                                             # => Returns: current queue
  end
 
  # => Server callbacks
  @impl true
  def init(_opts) do
    {:ok, []}                                # => Initial state: empty list
  end
 
  @impl true
  def handle_cast({:enqueue, notification}, queue) do
    new_queue = [notification | queue]       # => Add to queue
    {:noreply, new_queue}                    # => No reply, update state
                                             # => Type: {:noreply, new_state}
  end
 
  @impl true
  def handle_call(:get_queue, _from, queue) do
    {:reply, queue, queue}                   # => Return queue, state unchanged
  end
end

Usage:

{:ok, _pid} = NotificationQueue.start_link([])
 
NotificationQueue.enqueue("Email notification")
# => Returns: :ok (immediately)
# => Notification queued asynchronously
 
NotificationQueue.enqueue("SMS notification")
# => Returns: :ok
 
NotificationQueue.get_queue()
# => Returns: ["SMS notification", "Email notification"]

Pattern 3: Handling Unexpected Messages with handle_info

handle_info catches messages not sent via call or cast:

defmodule PeriodicReporter do
  use GenServer
 
  # => Client API
  def start_link(interval_ms) do
    GenServer.start_link(__MODULE__, interval_ms, name: __MODULE__)
  end
 
  # => Server callbacks
  @impl true
  def init(interval_ms) do
    schedule_report(interval_ms)             # => Schedule first report
    {:ok, %{interval: interval_ms, count: 0}}
                                             # => Initial state
  end
 
  @impl true
  def handle_info(:report, state) do
    # => Periodic message from Process.send_after
    IO.puts("Report ##{state.count}: #{DateTime.utc_now()}")
    schedule_report(state.interval)          # => Schedule next report
    {:noreply, %{state | count: state.count + 1}}
                                             # => Update count, continue
  end
 
  defp schedule_report(interval_ms) do
    Process.send_after(self(), :report, interval_ms)
                                             # => Send :report after interval
                                             # => Returns: timer reference
  end
end

Usage:

{:ok, _pid} = PeriodicReporter.start_link(5000)
# => Reports every 5 seconds
# => Output: Report #0: 2026-02-05 10:00:00Z
# => Output: Report #1: 2026-02-05 10:00:05Z
# => Output: Report #2: 2026-02-05 10:00:10Z

Pattern 4: Graceful Shutdown with terminate

terminate/2 provides cleanup on process exit:

defmodule DatabasePool do
  use GenServer
 
  # => Client API
  def start_link(config) do
    GenServer.start_link(__MODULE__, config, name: __MODULE__)
  end
 
  # => Server callbacks
  @impl true
  def init(config) do
    # => Open database connections
    connections = Enum.map(1..config.pool_size, fn _ ->
      {:ok, conn} = :db.connect(config.url)
      conn
    end)
    # => connections: List of connection handles
 
    {:ok, %{config: config, connections: connections}}
                                             # => Initial state: pool
  end
 
  @impl true
  def terminate(reason, state) do
    # => Called on shutdown
    IO.puts("Shutting down pool: #{inspect(reason)}")
 
    Enum.each(state.connections, fn conn ->
      :db.close(conn)                        # => Close each connection
    end)
    # => All resources released
 
    :ok
  end
 
  # => ... handle_call/handle_cast implementations ...
end

Shutdown scenarios:

{:ok, pid} = DatabasePool.start_link(%{pool_size: 10, url: "db://..."})
# => 10 connections opened
 
# Graceful shutdown
GenServer.stop(pid)
# => Calls terminate(:normal, state)
# => All connections closed
# => Returns: :ok
 
# Supervisor kills process
Process.exit(pid, :shutdown)
# => Calls terminate(:shutdown, state)
# => All connections closed

Pattern 5: Timeout and Hibernation

Control process lifecycle with timeouts:

defmodule CacheServer do
  use GenServer
 
  # => Client API
  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end
 
  def put(key, value) do
    GenServer.cast(__MODULE__, {:put, key, value})
  end
 
  def get(key) do
    GenServer.call(__MODULE__, {:get, key})
  end
 
  # => Server callbacks
  @impl true                                 # => GenServer init callback
                                             # => Called on start_link
  def init(_opts) do                         # => _opts: Initialization arguments (unused)
                                             # => Runs once at startup
    {:ok, %{}, 60_000}                       # => Three-element tuple
                                             # => :ok: Successful initialization
                                             # => %{}: Empty map as initial state
                                             # => 60_000: Hibernate after 60s idle
                                             # => If no messages for 60s, timeout
                                             # => Type: {:ok, state(), timeout()}
  end
 
  @impl true                                 # => Async message handler
                                             # => No reply expected
  def handle_cast({:put, key, value}, cache) do
                                             # => {:put, key, value}: Message pattern
                                             # => cache: Current cache state (map)
                                             # => Runs in GenServer process
    new_cache = Map.put(cache, key, value)   # => Add key-value pair to map
                                             # => new_cache: Updated cache map
                                             # => Immutable update (new map created)
                                             # => Type: map()
    {:noreply, new_cache, 60_000}            # => Three-element tuple
                                             # => :noreply: No reply sent
                                             # => new_cache: Updated state
                                             # => 60_000: Reset 60s timeout
                                             # => Activity detected
                                             # => Type: {:noreply, state(), timeout()}
  end
 
  @impl true                                 # => Sync message handler
                                             # => Caller waits for reply
  def handle_call({:get, key}, _from, cache) do
                                             # => {:get, key}: Message pattern with key
                                             # => _from: Caller PID (unused)
                                             # => cache: Current cache state
    {:reply, Map.get(cache, key), cache, 60_000}
                                             # => Four-element tuple
                                             # => Map.get(cache, key): Value or nil
                                             # => cache: State unchanged
                                             # => 60_000: Reset timeout on read
                                             # => Type: {:reply, term(), state(), timeout()}
  end
 
  @impl true                                 # => Handle info messages
                                             # => For non-call/cast messages
  def handle_info(:timeout, cache) do        # => :timeout: Sent after idle period
                                             # => cache: Current state
                                             # => Triggered by 60s idle
    # => 60s of inactivity                   # => No messages received
    IO.puts("Cache idle, hibernating...")     # => Log hibernation event
                                             # => Output to console
    {:noreply, cache, :hibernate}            # => Three-element tuple
                                             # => cache: State preserved
                                             # => :hibernate: Garbage collect, minimize memory
                                             # => Wakes on next message
                                             # => Type: {:noreply, state(), :hibernate}
  end
end

Trade-offs: Manual vs GenServer

AspectManual ProcessesGenServer
ComplexitySimple concepts, verbose codeMore concepts, concise code
OTP ComplianceManual implementation requiredBuilt-in
Supervision SupportLimitedFull OTP integration
BoilerplateHigh (send/receive everywhere)Low (behavior abstracts)
Message HandlingManual pattern matchingStructured callbacks
Lifecycle ManagementManual hooksBuilt-in init/terminate
Timeout SupportManual timer logicBuilt-in timeout parameter
Debug SupportCustom implementationsys module integration
Hot Code UpgradeNot supportedSupported via code_change
Learning CurveLower (basic primitives)Higher (behavior conventions)
Production ReadinessRequires extensive validationProduction-tested abstraction
Recommended UseLearning, prototypingProduction systems

Recommendation: Use GenServer for all production stateful processes. Manual processes are valuable for learning BEAM fundamentals but require too much careful work to make production-ready.

Best Practices

1. Separate Client and Server Code

# Good: Clear separation
defmodule Counter do
  use GenServer
 
  # => Client API (runs in caller's process)
  def start_link(initial), do: GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  def increment, do: GenServer.call(__MODULE__, :increment)
  def get, do: GenServer.call(__MODULE__, :get)
 
  # => Server callbacks (runs in GenServer process)
  @impl true
  def init(initial), do: {:ok, initial}
 
  @impl true
  def handle_call(:increment, _from, state), do: {:reply, state + 1, state + 1}
 
  @impl true
  def handle_call(:get, _from, state), do: {:reply, state, state}
end

2. Use @impl Attribute

Mark callback implementations explicitly:

defmodule MyGenServer do
  use GenServer
 
  @impl true                                 # => Marks as behavior callback
  def init(_opts) do                         # => Compiler verifies signature
    {:ok, %{}}                               # => Warns if typo or wrong arity
  end
 
  @impl true
  def handle_call(:get, _from, state) do
    {:reply, state, state}
  end
end

3. Name Processes for Easy Access

# Bad: Passing pids everywhere
{:ok, pid} = Counter.start_link(0)
Counter.increment(pid)
 
# Good: Named process
def start_link(initial) do
  GenServer.start_link(__MODULE__, initial, name: __MODULE__)
                                             # => Registered name
end
 
Counter.increment()                          # => No pid needed

4. Use Registry for Dynamic Processes

# Multiple processes of same type
defmodule SessionManager do
  def start_link(session_id) do
    GenServer.start_link(__MODULE__, session_id,
      name: via_tuple(session_id))           # => Dynamic naming
  end
 
  defp via_tuple(session_id) do
    {:via, Registry, {SessionRegistry, session_id}}
                                             # => Registry-based lookup
  end
end
 
# Usage
{:ok, _} = Registry.start_link(keys: :unique, name: SessionRegistry)
SessionManager.start_link("session-123")
SessionManager.start_link("session-456")

5. Handle All States Explicitly

# Bad: Missing state handling
def handle_call(:approve, _from, contract) do
  {:reply, :ok, %{contract | state: :approved}}
end
# => Allows approve from any state
 
# Good: Explicit state guards
def handle_call(:approve, _from, %{state: :pending} = contract) do
  {:reply, :ok, %{contract | state: :approved}}
end
 
def handle_call(:approve, _from, contract) do
  {:reply, {:error, :invalid_state}, contract}
end
# => Only approve from :pending state

6. Use Timeouts for Long Operations

# Client-side timeout
GenServer.call(pid, :expensive_operation, 30_000)
                                             # => 30s timeout
                                             # => Prevents indefinite blocking
 
# Server-side hibernate timeout
def handle_call(:get, _from, state) do
  {:reply, state, state, 60_000}             # => Hibernate after 60s idle
end

7. Implement terminate for Cleanup

@impl true
def terminate(reason, state) do
  # => Release external resources
  close_connections(state.connections)
  cleanup_files(state.temp_files)
  persist_state(state)                       # => Save state before exit
  :ok
end

When to Use GenServer

Use GenServer when:

  • Managing mutable state (counters, caches, connections)
  • Building stateful services (session managers, workers)
  • Implementing state machines (workflows, contracts)
  • Need OTP supervision integration
  • Require structured lifecycle management

Consider alternatives when:

  • Task - For one-off computations without state
  • Agent - For simple get/update state (GenServer wrapper)
  • GenStage - For backpressure-aware data pipelines
  • Registry - For process lookup without state

Next Steps

Completed: GenServer patterns for state management

Continue learning:

Foundation knowledge:

Quick reference:


Summary: GenServer provides production-ready state management through standardized callbacks, OTP compliance, and built-in lifecycle support. Start with manual processes to understand state management challenges, then adopt GenServer for production systems requiring supervision, timeouts, and graceful shutdown.

Last updated February 4, 2026

Command Palette

Search for a command to run...