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_otp

Building 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.

Leave a Comment

Your email address will not be published. Required fields are marked *