Station 04Open the adventure map
00Set up your toolsSetup01The starting lineSetup02Processes and mailboxesR1An Elixir data pipelineOptional reviewR2Read ErlangOptional review03Messages and timeouts04OTP rules for messages05A supervision tree that restarts06Put a limit on concurrency07When a BEAM node disappearsX1Agree on shared BEAM termsBilingual extensionX2Two-language partnersBilingual extension08A reliable job runner
Home/BEAM mainline/Station 04
04
OTPIntermediate explorationElixirErlangOTP

OTP rules for messages

GenServer organizes receiving, replying, and system messages into rules everyone can recognize.

5checkpoints
About 7 hours · Try 5 sessionssplit it into sessions
QUESTION · ONE PROBLEM FOR THIS STATION

After replacing a hand-written loop with GenServer, how should call and cast be divided, and how can slow work be kept from filling the mailbox?

Start at the scene

Senders are fast, but the service gets slower and slower

Callers keep casting while the service performs slow I/O inside a callback. Sending never waits, the mailbox grows, and later synchronous queries begin to time out.

Evidence you can observe
  • Mailbox length over time
  • Call latency and timeout count
  • Input rate, completion rate, and busy rejection count
Why this station matters

The problem it solves

Rewriting startup, system messages, and replies every time makes details easy to miss. An OTP behaviour supplies a common pattern. We still decide the API, state owner, and overload policy.

After this station

You will be able to

  • Separate the public API used by callers from callbacks that handle messages inside the process

  • Choose call or cast by asking whether a result is required and what happens when the service is too busy

  • Put a long-running GenServer or gen_server under a supervision tree

Before you start
  • Finish the messages and mailboxes station and write one message loop by hand
  • Know the jobs of requests, replies, references, and timeouts
One job, two ways to write it

See what it does before how it is written

The API hides message shapes, and callbacks follow the contract. Both languages return tuples with the same meaning.

Current code
Elixir
# GenServer owns this map and updates it in order
defmodule KV do
  use GenServer

  # These functions are the public API seen by callers
  def start_link(opts), do: GenServer.start_link(__MODULE__, %{}, opts)
  def get(server, key), do: GenServer.call(server, {:get, key})
  def put(server, key, value), do: GenServer.call(server, {:put, key, value})

  # Callbacks handle the real messages and state
  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_call({:get, key}, _from, state),
    do: {:reply, Map.fetch(state, key), state}

  def handle_call({:put, key, value}, _from, state),
    do: {:reply, :ok, Map.put(state, key, value)}
end
DESIGN DECISION

Why this shape?

Why replace a hand-written loop with GenServer/gen_server?

Choice

OTP standardizes startup, calls, casts, and system messages so supervisors and tools recognize the service.

Cost and boundary

The framework does not create capacity limits or make a slow callback fast.

A FAMILIAR POINT OF VIEW

Coming from Java, Python, or JavaScript

Java

Familiar starting point
An interface plus a service object.
What BEAM changes
A behaviour defines callbacks; GenServer adds a process protocol.
False friend
One GenServer runs callbacks serially. It is not an automatic thread pool.

Python

Familiar starting point
An ABC/Protocol plus a long-running task.
What BEAM changes
The public API and callback layer are intentionally separate, with one process owning state.
False friend
A cast skips a reply; it does not remove queue pressure.

JavaScript

Familiar starting point
A class, event emitter, or async handler.
What BEAM changes
Calls, casts, and system messages share one process protocol.
False friend
A slow callback delays the messages behind it.
LAB
Hands on

Sending fast is not finishing fast

About 15–25 minutes

Send updates quickly with cast, then watch the mailbox and the delay of a synchronous query.

  1. 01

    Add put_async/3 using cast

  2. 02

    Send a large batch of updates and record when sending ends and when processing ends

  3. 03

    Make a synchronous get at the same time and record its wait and message_queue_len

Copy into the terminal and press Enter
# Read the pid's mailbox length and see whether casts are piling up
:erlang.process_info(pid, :message_queue_len)
What you should see
  • The sending loop can finish quickly even while the server still has work left
  • A synchronous get may wait behind many casts and take much longer
Break it on purpose

Add slow I/O inside handle_cast and send even faster. See whether the mailbox keeps growing.

What this shows

An asynchronous API lets the caller leave without waiting, but the wait may simply move into the server mailbox.

What this does not show yet

A local experiment cannot set the limit for a real service. Hardware, message size, and outside I/O all change the result.

Meet the words

Key ideas in this code

01

behaviour

A contract made of callbacks. A module implements the required callbacks while the framework handles the message loop, system messages, and debugging support.

02

call

A synchronous request that needs a reply. The caller waits, but waiting alone is not a complete backpressure plan.

03

cast

An asynchronous message with no reply. When senders are too fast, casts pile up in the mailbox.

Name the shape in the code

Design patterns used here

01

Template Method

Let the behaviour fix the lifecycle and callback skeleton while business code fills the required steps.

02

Facade

Hide raw message shapes behind a clear client API.

03

Bounded Buffer

Cap pending work and return busy explicitly when capacity is full.

Think it through

Which situation is the best reason to consider cast?

Your turn

Build a bounded task service

Write the API in one language and the worker in the other. Run at most N tasks, and return busy when the bounded queue is full.

Hint 1Take the first step

Let one process own the queue and capacity, but give each real task to its own process.

Hint 2Make it a little smaller

Use a monitor to notice both worker completion and abnormal exits, and always return capacity.

Hint 3You are close

Do not quietly accept unlimited tasks. Tell callers when the service is full.

Hint 4Work backward from the finish

Choose one success signal and write the smallest test for it. If the computer cannot show the result, rewrite the signal as something you can truly observe.

Ready to move on when
  • The concurrency limit and waiting-queue limit are both visible

  • The capacity count returns to the correct value even after a worker crashes

  • A caller clearly learns whether a task was accepted or rejected as busy

Take these with you

Remember three things

  1. 1

    A behaviour supplies reliable message-loop rules, but the business agreement is still ours to design.

  2. 2

    A friendly client API hides internal message shapes so callers can state their purpose.

  3. 3

    A cast lets the sender leave, but may leave pressure in the mailbox. Capacity and overload handling must be designed clearly.

Read a little more

Visit the original sources

Elixir GenServerErlang gen_server
Station completeYou ran the experiment and thought through the answer. Save this station.