How Speechwave holds state without objects
Primary source: GenServer docs, which have a thorough walkthrough. For background on processes: Elixir Getting Started: Processes.
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.
Every GenServer has two halves:
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.
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 | What it does |
|---|---|
use GenServer | Injects the GenServer behaviour, providing default implementations of all callbacks |
@table :rate_limiter | Name of the ETS table (an atom) |
start_link/1 | Client API. Starts the GenServer process, registering it under its module name so you can find it |
init/1 | Server callback. Runs once when the process starts. Creates the ETS table and returns the initial state (%{}) |
allow?/1 | Client API. Checks the ETS table directly. Doesn't go through the GenServer message queue |
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 RateLimiter only uses init/1. A full GenServer uses up to three more callbacks. Here's how they map to concepts you already know:
| Callback | Triggered by | Returns | Like in LiveView |
|---|---|---|---|
init/1 | Process starting | {:ok, state} | mount/3 |
handle_call/3 | GenServer.call | {:reply, response, state} | n/a |
handle_cast/2 | GenServer.cast | {:noreply, state} | n/a |
handle_info/2 | Raw messages / timers | {:noreply, state} | handle_info/2 |
The difference between call and cast:
call: synchronous. The caller waits for a response. Use when you need an answer back. "Call and wait."cast: asynchronous. The caller fires and forgets. Use when you just need to tell the process something. "Cast and continue."This GenServer uses handle_info to implement a recurring timer, a common pattern.
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:
init/1 schedules a :backup message to be delivered to itself after 5 minuteshandle_info(:backup, state) runs the backup:backup message in 1 hourProcess.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.
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 trueYou'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.
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.
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:
case matches ETS tuples with a guard clause on timing^ip in the ETS lookup result ensures the match is for the right key{ip, last_at, cooldown_ms, violation_count} stores four values per entry@ip_base_cooldown_ms, @ip_max_cooldown_ms for configurationThe 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.
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.
Why does RateLimiter use ETS instead of storing state in the GenServer's state argument?
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).In DbBackup, what does Process.send_after(self(), :backup, @interval) do?
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.What happens if Speechwave.RateLimiter crashes at runtime?
: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.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.What is the difference between handle_call and handle_info?
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.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)