Gleam OTP Actors
OTP Actors are the standard way to build stateful, concurrent components in Gleam. An actor is a process that holds state, handles typed messages, and returns updated state after each message. The gleam_otp package provides the actor module that manages the process loop, error handling, and supervision automatically.
What Is an Actor?
Actor Model
──────────────────────────────────────────────────
┌──────────────────────────┐
Sender ───▶│ Message Queue (mailbox) │
│ │
│ State: 42 │
│ Handler: │
│ msg, state → new state│
└──────────────────────────┘
Actor Process
Messages arrive in order.
Handler processes one at a time.
State persists between messages.
Adding the Dependency
gleam add gleam_otpBuilding an Actor — Counter Example
import gleam/otp/actor
import gleam/erlang/process.{type Subject}
import gleam/io
// 1. Define the message type
type Message {
Increment
Decrement
GetCount(reply_to: Subject(Int))
Shutdown
}
// 2. Define the handler
fn handle_message(msg: Message, count: Int) -> actor.Next(Message, Int) {
case msg {
Increment ->
actor.continue(count + 1)
Decrement ->
actor.continue(count - 1)
GetCount(reply_to) -> {
process.send(reply_to, count)
actor.continue(count)
}
Shutdown ->
actor.Stop(process.Normal)
}
}
// 3. Start the actor
pub fn start_counter() {
actor.start(0, handle_message)
}
Actor Lifecycle
──────────────────────────────────────────────────
actor.start(initial_state, handler)
│
▼
state = 0
│
┌──────┴──────┐
│ receive │ ← wait for next message
│ message │
└──────┬──────┘
│
handler(msg, state)
│
actor.continue(new_state) ← loop back
actor.Stop(reason) ← exit
Using the Actor
import gleam/otp/actor
import gleam/erlang/process
import gleam/io
pub fn main() {
let assert Ok(counter) = start_counter()
actor.send(counter, Increment)
actor.send(counter, Increment)
actor.send(counter, Increment)
actor.send(counter, Decrement)
let reply = process.new_subject()
actor.send(counter, GetCount(reply))
let count = process.receive(reply, 1000)
// Ok(2)
actor.send(counter, Shutdown)
}
actor.Next — What the Handler Returns
Handler Return Values
──────────────────────────────────────────────────
actor.continue(new_state)
→ Keep running with new_state
actor.continue_with_timeout(new_state, ms)
→ Keep running, send a timeout message after ms
actor.Stop(process.Normal)
→ Shut down cleanly
actor.Stop(process.Abnormal("reason"))
→ Shut down with an error (supervisor may restart)
Actor with Complex State
import gleam/map
type CacheMsg {
Put(key: String, value: String)
Get(key: String, reply_to: Subject(Result(String, Nil)))
Clear
}
type CacheState = Map(String, String)
fn cache_handler(msg: CacheMsg, state: CacheState) -> actor.Next(CacheMsg, CacheState) {
case msg {
Put(key, value) ->
actor.continue(map.insert(state, key, value))
Get(key, reply) -> {
let result = map.get(state, key) |> result.map_error(fn(_) { Nil })
process.send(reply, result)
actor.continue(state)
}
Clear ->
actor.continue(map.new())
}
}
pub fn start_cache() {
actor.start(map.new(), cache_handler)
}
Cache Actor State
──────────────────────────────────────────────────
Initial state: {} (empty map)
Put("name", "Gleam") → {"name": "Gleam"}
Put("version", "1") → {"name": "Gleam", "version": "1"}
Get("name") → Ok("Gleam"), state unchanged
Clear → {}
Actors vs Raw Processes
Comparison
──────────────────────────────────────────────────
Raw process │ Actor
─────────────────────────┼──────────────────────────
Manual loop recursion │ Loop managed by actor module
No automatic restart │ Supervisor-compatible
Manual error handling │ Errors handled gracefully
More boilerplate │ Less boilerplate
Maximum flexibility │ Standard, proven pattern
Practical Example — Rate Limiter
import gleam/otp/actor
import gleam/erlang/process
type LimiterMsg {
RequestAccess(client: String, reply_to: Subject(Bool))
Reset
}
type LimiterState {
LimiterState(counts: Map(String, Int), limit: Int)
}
fn limiter_handler(msg: LimiterMsg, state: LimiterState) -> actor.Next(LimiterMsg, LimiterState) {
case msg {
RequestAccess(client, reply) -> {
let current = map.get(state.counts, client) |> result.unwrap(0)
let allowed = current < state.limit
process.send(reply, allowed)
let new_counts = case allowed {
True -> map.insert(state.counts, client, current + 1)
False -> state.counts
}
actor.continue(LimiterState(..state, counts: new_counts))
}
Reset ->
actor.continue(LimiterState(..state, counts: map.new()))
}
}
Key Points
Actor Essentials
──────────────────────────────────────────────────
1. actor.start(initial_state, handler) starts an actor
2. Handler signature: fn(Message, State) -> Next(Msg, State)
3. actor.continue(new_state) loops with updated state
4. actor.Stop(reason) shuts down the actor
5. actor.send(actor, msg) sends a message
6. Actors are supervised and can restart on failure
7. State is private — only accessible through messages
Actors give you stateful concurrency without shared memory or locks. Each actor owns its state completely. The message queue ensures thread-safe access automatically. This model scales from a simple counter to the state management core of production distributed systems.
