Let Erlang Crash

A fun, irreverent guide to the world's most indestructible programming language

View on GitHub

Chapter 16: GenServer: Your New Best Friend

If Erlang had a greatest hits album, GenServer would be the lead single. It’s the most-used OTP behaviour by a mile. Every stateful service, every connection handler, every cache, every worker — they’re all GenServers. Once you understand it, you understand 60% of production Erlang code.


What Is a GenServer?

GenServer stands for “Generic Server.” It’s an OTP behaviour that implements the client-server pattern:

GenServer handles all the boring stuff — the receive loop, message format, timeouts, error handling, code upgrades. You just implement the callbacks that say what the server does.

The Callbacks

A GenServer module implements these callbacks:

Callback Called When Must Return
init/1 Server starts {ok, State}
handle_call/3 Sync request (call) {reply, Reply, NewState}
handle_cast/2 Async request (cast) {noreply, NewState}
handle_info/2 Any other message {noreply, NewState}
terminate/2 Server is shutting down Ignored

That’s it. Five callbacks, and terminate/2 is optional. Let’s build something.

A Key-Value Store

-module(kv_store).
-behaviour(gen_server).

%% Public API
-export([start_link/0, put/2, get/1, delete/1, all/0]).

%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2]).

%%% Public API %%%

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

put(Key, Value) ->
    gen_server:call(?MODULE, {put, Key, Value}).

get(Key) ->
    gen_server:call(?MODULE, {get, Key}).

delete(Key) ->
    gen_server:cast(?MODULE, {delete, Key}).

all() ->
    gen_server:call(?MODULE, all).

%%% Callbacks %%%

init([]) ->
    {ok, #{}}.  %% Initial state: empty map

handle_call({put, Key, Value}, _From, State) ->
    {reply, ok, State#{Key => Value}};

handle_call({get, Key}, _From, State) ->
    Reply = maps:get(Key, State, undefined),
    {reply, Reply, State};

handle_call(all, _From, State) ->
    {reply, State, State};

handle_call(_Request, _From, State) ->
    {reply, {error, unknown_request}, State}.

handle_cast({delete, Key}, State) ->
    {noreply, maps:remove(Key, State)};

handle_cast(_Msg, State) ->
    {noreply, State}.
1> kv_store:start_link().
{ok,<0.89.0>}
2> kv_store:put(name, "Alice").
ok
3> kv_store:put(age, 30).
ok
4> kv_store:get(name).
"Alice"
5> kv_store:all().
#{age => 30,name => "Alice"}
6> kv_store:delete(age).
ok
7> kv_store:all().
#{name => "Alice"}

A complete, concurrent, crash-recoverable key-value store in 35 lines of callback code.

call vs. cast

This is the most important distinction in GenServer:

gen_server:call/2 — Synchronous

%% Client blocks until the server replies
Result = gen_server:call(Server, Request).

gen_server:cast/2 — Asynchronous

%% Client sends and immediately continues
gen_server:cast(Server, Message).

When to Use Which

%% CALL: You need the result
Balance = gen_server:call(account_server, {get_balance, UserId}).

%% CALL: You need confirmation it worked
ok = gen_server:call(db_server, {write, Key, Value}).

%% CAST: Fire and forget
gen_server:cast(logger, {log, info, "User logged in"}).

%% CAST: Performance-critical, don't need confirmation
gen_server:cast(metrics, {increment, page_views}).

handle_info: Everything Else

handle_info/2 catches messages that aren’t calls or casts — things like:

handle_info({'DOWN', _Ref, process, Pid, Reason}, State) ->
    io:format("Monitored process ~p died: ~p~n", [Pid, Reason]),
    {noreply, cleanup(Pid, State)};

handle_info(tick, State) ->
    %% periodic work
    do_periodic_stuff(),
    erlang:send_after(1000, self(), tick),
    {noreply, State};

handle_info(_Unexpected, State) ->
    %% Log and ignore unexpected messages
    {noreply, State}.

Starting and Naming

%% Local name (atom)
gen_server:start_link({local, my_server}, ?MODULE, Args, []).

%% Global name (across nodes)
gen_server:start_link({global, my_server}, ?MODULE, Args, []).

%% No name (use PID)
{ok, Pid} = gen_server:start_link(?MODULE, Args, []).

%% start vs start_link
gen_server:start(...)       %% Not linked to caller
gen_server:start_link(...)  %% Linked to caller (use in supervisors)

Use start_link when starting under a supervisor (which is almost always). The link lets the supervisor detect when the GenServer crashes.

Timeouts

GenServer supports timeouts in several ways:

Reply with timeout

handle_call(something, _From, State) ->
    {reply, ok, State, 5000}.  %% Timeout after 5 seconds of inactivity

If no message arrives within 5000ms, handle_info(timeout, State) is called.

Call timeout

%% Client-side: wait at most 10 seconds for a reply
Result = gen_server:call(Server, Request, 10000).

Self-scheduling with send_after

init([]) ->
    erlang:send_after(1000, self(), tick),
    {ok, #{count => 0}}.

handle_info(tick, #{count := N} = State) ->
    io:format("Tick ~p~n", [N]),
    erlang:send_after(1000, self(), tick),
    {noreply, State#{count => N + 1}}.

State Management Patterns

Map state (most common)

init([]) ->
    {ok, #{users => [], count => 0}}.

handle_call({add_user, User}, _From, #{users := Users, count := N} = State) ->
    {reply, ok, State#{users => [User | Users], count => N + 1}}.

Record state

-record(state, {connections = [], max_conns = 100, name}).

init([Name, MaxConns]) ->
    {ok, #state{name = Name, max_conns = MaxConns}}.

handle_call(status, _From, #state{connections = Conns, max_conns = Max} = State) ->
    {reply, #{active => length(Conns), max => Max}, State}.

A Real-World Example: Rate Limiter

-module(rate_limiter).
-behaviour(gen_server).
-export([start_link/2, allow/1]).
-export([init/1, handle_call/3, handle_info/2]).

start_link(Name, MaxPerSecond) ->
    gen_server:start_link({local, Name}, ?MODULE, MaxPerSecond, []).

allow(Name) ->
    gen_server:call(Name, allow).

init(MaxPerSecond) ->
    erlang:send_after(1000, self(), reset),
    {ok, #{max => MaxPerSecond, remaining => MaxPerSecond}}.

handle_call(allow, _From, #{remaining := 0} = State) ->
    {reply, {error, rate_limited}, State};
handle_call(allow, _From, #{remaining := N} = State) ->
    {reply, ok, State#{remaining => N - 1}}.

handle_info(reset, #{max := Max} = State) ->
    erlang:send_after(1000, self(), reset),
    {noreply, State#{remaining => Max}}.
1> rate_limiter:start_link(api_limiter, 3).
{ok,<0.89.0>}
2> rate_limiter:allow(api_limiter).
ok
3> rate_limiter:allow(api_limiter).
ok
4> rate_limiter:allow(api_limiter).
ok
5> rate_limiter:allow(api_limiter).
{error,rate_limited}
%% Wait a second...
6> rate_limiter:allow(api_limiter).
ok

A production-quality rate limiter in 25 lines.

Common Gotchas

The bottleneck problem

A GenServer processes one message at a time. If your server is slow, calls queue up. Solutions:

Don’t block in callbacks

%% BAD: blocks the server for 30 seconds
handle_call(fetch_data, _From, State) ->
    Data = http:get("https://slow-api.com/data"),  %% 30 second timeout
    {reply, Data, State}.

%% GOOD: do expensive work in a separate process
handle_call(fetch_data, From, State) ->
    spawn(fun() ->
        Data = http:get("https://slow-api.com/data"),
        gen_server:reply(From, Data)
    end),
    {noreply, State}.

Don’t forget the catch-all clauses

Always add a catch-all clause for each callback to handle unexpected messages gracefully.

Key Takeaways

GenServer is the workhorse of Erlang. Once you’re comfortable with it, you can build almost anything — caches, connection pools, rate limiters, state machines, workers. It’s the pattern you’ll reach for first and use most often.


← Previous: OTP Next: Supervisors →