← docs.speechwave.live

Lesson 7: GenServer, stateful processes

How Speechwave holds state without objects

Primary source: GenServer docs, which have a thorough walkthrough. For background on processes: Elixir Getting Started: Processes.

The problem GenServer solves

Everything in Elixir is immutable. You can't update a variable, can't modify a map in place, can't store something globally. So how does Speechwave's rate limiter remember when each session last reacted?

The answer is processes. A GenServer is a long-lived process that holds state in memory. Other processes send it messages, and it responds, updating its internal state as needed. It's Elixir's answer to the problem that objects solve in OOP: encapsulating state with behavior.

Ruby parallel: Imagine a Ruby object that lives in its own thread, has private instance variables, and only communicates via method calls that go through a thread-safe queue. That's roughly what a GenServer is, except the BEAM VM makes this lightweight enough to run thousands of them.

The shape of a GenServer

Every GenServer has two halves:

GenServer Module ┌──────────────────────────────────────────┐ │ │ │ CLIENT API (called by other processes) │ │ ──────────────────────────────────── │ │ start_link/1 : start the process │ │ allow?/1 : public interface │ │ │ │ SERVER CALLBACKS (run inside process) │ │ ──────────────────────────────────── │ │ init/1 : set up initial state │ │ handle_call/3 : sync request/response │ │ handle_cast/2 : async fire-and-forget │ │ handle_info/2 : raw messages / timers │ │ │ └──────────────────────────────────────────┘

The client API runs in the caller's process. The callbacks run in the GenServer's process. The GenServer framework handles the message passing between them.

Speechwave's RateLimiter: the minimal GenServer

Let's read the simplest GenServer in your codebase line by line:

lib/speechwave/rate_limiter.ex

defmodule Speechwave.RateLimiter do
  use GenServer
  @table :rate_limiter

  def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)

  def init(_) do
    :ets.new(@table, [:named_table, :public, read_concurrency: true])
    {:ok, %{}}
  end

  def allow?(session_id) do
    now = System.monotonic_time(:millisecond)

    case :ets.lookup(@table, session_id) do
      [{^session_id, last_at}] when now - last_at < @cooldown_ms ->
        false

      _ ->
        :ets.insert(@table, {session_id, now})
        true
    end
  end
end

Line by line

LineWhat it does
use GenServerInjects the GenServer behaviour, providing default implementations of all callbacks
@table :rate_limiterName of the ETS table (an atom)
start_link/1Client API. Starts the GenServer process, registering it under its module name so you can find it
init/1Server callback. Runs once when the process starts. Creates the ETS table and returns the initial state (%{})
allow?/1Client API. Checks the ETS table directly. Doesn't go through the GenServer message queue
Key insight: This GenServer is unusual: it uses ETS (a shared in-memory table) instead of GenServer state. The GenServer only exists to own the ETS table (the table is destroyed when its owning process dies). The allow?/1 function reads/writes ETS directly from the caller's process, which is faster than sending a message to the GenServer and waiting for a reply. This is a performance optimization for hot paths like rate limiting.

The three callback types

The RateLimiter only uses init/1. A full GenServer uses up to three more callbacks. Here's how they map to concepts you already know:

CallbackTriggered byReturnsLike in LiveView
init/1Process starting{:ok, state}mount/3
handle_call/3GenServer.call{:reply, response, state}n/a
handle_cast/2GenServer.cast{:noreply, state}n/a
handle_info/2Raw messages / timers{:noreply, state}handle_info/2

The difference between call and cast:

DbBackup: GenServer with timers

This GenServer uses handle_info to implement a recurring timer, a common pattern.

lib/speechwave/db_backup.ex

defmodule Speechwave.DbBackup do
  use GenServer
  require Logger

  @initial_delay :timer.minutes(5)
  @interval :timer.hours(1)

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

  @impl true
  def init(_opts) do
    Process.send_after(self(), :backup, @initial_delay)
    {:ok, %{}}
  end

  @impl true
  def handle_info(:backup, state) do
    run_backup()
    Process.send_after(self(), :backup, @interval)
    {:noreply, state}
  end

  def run_now, do: run_backup()

  defp run_backup do
    # ... VACUUM INTO, upload to S3
  end
end

The timer pattern works like this:

  1. init/1 schedules a :backup message to be delivered to itself after 5 minutes
  2. When the message arrives, handle_info(:backup, state) runs the backup
  3. It then schedules the next :backup message in 1 hour
  4. This creates an infinite loop: backup → schedule → wait → backup → schedule → ...

Process.send_after(self(), message, delay_ms) is how you build timers in Elixir. self() returns the current process's ID. The message arrives like any other message, and handle_info picks it up.

Key insight: handle_info/2 is the same callback that LiveView uses for PubSub messages. In a LiveView, PubSub broadcasts arrive as handle_info. In a GenServer, timer messages and any other raw messages arrive the same way. It's the universal "something sent me a message" handler.

@impl true

You'll notice @impl true before the callbacks. This tells the compiler "this function implements a behaviour callback." If you misspell the function name or get the arity wrong, the compiler warns you. It's optional but good practice. It makes it clear which functions are callbacks vs. your own functions.

How GenServers get started: the supervision tree

GenServers don't start themselves. They're started by a supervisor, a process whose only job is to start children and restart them if they crash.

lib/speechwave/application.ex:12-22

children =
  [
    SpeechwaveWeb.Telemetry,
    Speechwave.Repo,
    {DNSCluster, query: Application.get_env(:speechwave, :dns_cluster_query) || :ignore},
    {Phoenix.PubSub, name: Speechwave.PubSub},
    Speechwave.RateLimiter,           # ← started here
    Speechwave.AuthThrottle,          # ← started here
    SpeechwaveWeb.Endpoint,
    SpeechwaveWeb.Presence
  ] ++ backup_children()

opts = [strategy: :one_for_one, name: Speechwave.Supervisor]
Supervisor.start_link(children, opts)

Each entry in the children list is a process that the supervisor starts in order. When you see a module name like Speechwave.RateLimiter, the supervisor calls RateLimiter.start_link/1, which starts the GenServer.

The :one_for_one strategy means: if one child crashes, restart just that one. If the RateLimiter crashes, it restarts fresh. The ETS table is recreated in init, and rate limiting resumes (all sessions get a fresh start). The rest of the app keeps running.

Ruby parallel: There's no direct equivalent. The closest is a process manager like systemd or foreman restarting a crashed worker. But Elixir supervisors are inside the application, react in milliseconds, and are the standard way to manage any stateful component. "Let it crash and restart clean" is the core philosophy. Instead of defensive error handling everywhere, you let processes fail and rely on supervision to recover.

AuthThrottle: a richer example

AuthThrottle follows the same ETS-backed pattern as RateLimiter but with more complex logic: escalating cooldowns for IP throttling.

lib/speechwave/auth_throttle.ex:68-88

def allow_ip?(ip) when is_binary(ip) do
  now = System.monotonic_time(:millisecond)

  case :ets.lookup(@ip_table, ip) do
    [{^ip, last_at, cooldown_ms, violation_count}] when now - last_at < cooldown_ms ->
      {new_cooldown_ms, new_violation_count} = bump_cooldown(cooldown_ms, violation_count)
      :ets.insert(@ip_table, {ip, now, new_cooldown_ms, new_violation_count})
      false

    _ ->
      :ets.insert(@ip_table, {ip, now, @ip_base_cooldown_ms, 0})
      true
  end
end

Everything you've learned converges here:

The test file for AuthThrottle (at test/speechwave/auth_throttle_test.exs) is worth reading. It manipulates ETS timestamps directly to simulate time passing, which is a clean testing pattern for time-dependent behavior.

When to use a GenServer

Most of the time, you don't need one. GenServers are for:

If you just need to transform data or query the database, use plain functions in context modules. GenServers add complexity. Only reach for them when you need persistent in-process state or timed behavior.


Practice

Question 1

Why does RateLimiter use ETS instead of storing state in the GenServer's state argument?

Right. If allow?/1 had to send a message to the GenServer and wait for a reply, every emoji tap would queue behind every other tap. With a :public ETS table, each LiveView process reads and writes directly, with no bottleneck. The GenServer only exists to own the table (so it's created on startup and recreated if the process restarts).
GenServer state can hold any Elixir term and has no size limit. ETS doesn't persist to disk (it's in-memory). The key difference is about how the data is accessed. Think about what happens when many LiveView processes need to check the rate limit simultaneously. Try again.

Question 2

In DbBackup, what does Process.send_after(self(), :backup, @interval) do?

Exactly. self() is the current process (the GenServer). After @interval milliseconds, the :backup atom arrives in the process's mailbox, which triggers handle_info(:backup, state). That callback runs the backup and schedules the next one, creating the recurring loop.
Process.send_after sends a message to a process. self() refers to the current process. When the message arrives, which callback handles it? Try again.

Question 3

What happens if Speechwave.RateLimiter crashes at runtime?

Right. The :one_for_one supervisor detects the crash and restarts just the RateLimiter. init/1 runs again, creates a new ETS table, and the process is back. The old table is gone (ETS tables are owned by their creating process), so all rate limits reset, which is fine, since a brief reset is better than downtime. This is "let it crash" in action.
Think about the supervision tree in application.ex. The :one_for_one strategy tells you what happens when a child crashes. And ETS tables are tied to their owning process. Try again.

Question 4

What is the difference between handle_call and handle_info?

Correct. handle_call is the request/response callback. The caller blocks until it gets a {:reply, response, state} back. handle_info handles any other message that lands in the process mailbox: timer messages, PubSub broadcasts, or anything sent with plain send/2. Both run inside the GenServer process; the difference is whether a reply is expected.
Both callbacks run inside the same GenServer process, and both can modify state. The difference is about how the message arrived and whether the sender expects a response. Try again.

Your next step

Read lib/speechwave/auth_throttle.ex from top to bottom. It uses every pattern from this lesson plus concepts from earlier lessons (pattern matching with guards on ETS tuples, module attributes for configuration, multi-clause case). Then look at its test file. It demonstrates a clean technique for testing time-dependent code by backdating ETS entries.

If you want to experiment, try this in IEx:

iex -S mix
# Check the ETS table directly
:ets.tab2list(:rate_limiter)

# See what's in the auth throttle tables
:ets.tab2list(:auth_throttle_email)
:ets.tab2list(:auth_throttle_ip)