Skip to content

Pattern: Actor Model

Advanced

One Liner

Each actor has a mailbox and processes messages sequentially — no shared state, no locks, just message passing for safe concurrency.

Interactive Demo

Real-World Analogy

Coworkers communicating only through sealed envelopes in mailboxes. No one walks into another's office — you write a message, drop it in their mailbox, and go back to your own work. Each person processes their mail one message at a time.

Core Idea

An actor is a lightweight process with private state and a mailbox (message queue). Actors communicate exclusively by sending asynchronous messages. Each actor processes one message at a time, updating its state and optionally sending messages to other actors. This eliminates shared-state concurrency bugs by design.

text
  Actor A                    Actor B                    Actor C
  ┌──────────────────┐      ┌──────────────────┐      ┌──────────────────┐
  │ State: count=3   │      │ State: items=[]  │      │ State: total=0   │
  │                  │      │                  │      │                  │
  │ Mailbox:         │      │ Mailbox:         │      │ Mailbox:         │
  │ ┌──┬──┬──┐       │ send │ ┌──┬──┐          │      │ ┌──┐             │
  │ │m1│m2│m3│       │─────►│ │m4│m5│          │      │ │m6│             │
  │ └──┴──┴──┘       │      │ └──┴──┘          │      │ └──┘             │
  │ Processing: m1   │      │ Processing: m4   │      │ Idle             │
  └──────────────────┘      └──────────────────┘      └──────────────────┘
PropertyValue
ConcurrencyNo shared state — message passing only
ProcessingSequential per actor (one message at a time)
Failure isolationActor crash doesn't corrupt other actors
ScalabilityMillions of lightweight actors (Erlang: 2KB per process)

Try it yourself — send messages between actors and observe mailbox processing and state isolation:

Production Proof

ProjectSourceUsage
Akka (Scala)Actor.scala#L476-L547trait Actor — the core actor interface. Defines context, self, sender(), and def receive: Actor.Receive (L528) where every Akka actor specifies its message-handling behavior via a partial function. aroundReceive (L540-L546) is the dispatch hook.
Erlang/OTPerl_process.h#L1043-L1205struct process — the BEAM VM's representation of an Erlang process (actor). Key fields: sig_qs (L1107, signal/message queues — the mailbox), sig_inq (L1168, concurrent signal input queue), state (L1165, atomic process state flags). Each process is a lightweight actor with its own heap and mailbox.

Implementation

typescript
type MessageHandler<S> = (state: S, msg: unknown) => S;

class Actor<S> {
  private state: S;
  private mailbox: unknown[] = [];
  private processing = false;

  constructor(initialState: S, private handler: MessageHandler<S>) {
    this.state = initialState;
  }

  send(msg: unknown): void {
    this.mailbox.push(msg);
    if (!this.processing) this.processMailbox();
  }

  private processMailbox(): void {
    this.processing = true;
    while (this.mailbox.length > 0) {
      const msg = this.mailbox.shift()!;
      this.state = this.handler(this.state, msg);
    }
    this.processing = false;
  }

  getState(): S {
    return this.state;
  }
}
rust
use std::collections::VecDeque;

pub struct Actor<M, S> {
    state: S,
    mailbox: VecDeque<M>,
}

impl<M, S> Actor<M, S> {
    pub fn new(initial_state: S) -> Self {
        Actor { state: initial_state, mailbox: VecDeque::new() }
    }

    pub fn send(&mut self, msg: M) {
        self.mailbox.push_back(msg);
    }

    pub fn process<F>(&mut self, handler: F)
    where F: Fn(&S, M) -> S {
        while let Some(msg) = self.mailbox.pop_front() {
            self.state = handler(&self.state, msg);
        }
    }

    pub fn state(&self) -> &S {
        &self.state
    }
}
go
type Actor struct {
	state   interface{}
	mailbox chan interface{}
	handler func(state interface{}, msg interface{}) interface{}
}

func NewActor(initial interface{}, handler func(interface{}, interface{}) interface{}) *Actor {
	a := &Actor{
		state:   initial,
		mailbox: make(chan interface{}, 100),
		handler: handler,
	}
	go a.run()
	return a
}

func (a *Actor) Send(msg interface{}) {
	a.mailbox <- msg
}

func (a *Actor) run() {
	for msg := range a.mailbox {
		a.state = a.handler(a.state, msg)
	}
}
python
from collections import deque
from typing import Any, Callable

class Actor:
    def __init__(self, initial_state: Any, handler: Callable[[Any, Any], Any]):
        self.state = initial_state
        self.handler = handler
        self._mailbox: deque[Any] = deque()
        self._processing = False

    def send(self, msg: Any) -> None:
        self._mailbox.append(msg)
        if not self._processing:
            self._process_mailbox()

    def _process_mailbox(self) -> None:
        self._processing = True
        while self._mailbox:
            msg = self._mailbox.popleft()
            self.state = self.handler(self.state, msg)
        self._processing = False

    def get_state(self) -> Any:
        return self.state

Exercises

LevelExerciseFile
BasicImplement an actor with mailbox and message processingexercises/typescript/actor-model/01-basic.test.ts
IntermediateActor supervision — parent restarts crashed childrenexercises/typescript/actor-model/02-intermediate.test.ts

Run exercises: pnpm test:exercises (TypeScript) · cargo test (Rust) · go test ./... (Go) · pytest (Python)

Exercise files: Rust exercises/rust/src/actor_model/mod.rs · Go exercises/go/actor_model/actor_model_test.go · Python exercises/python/actor_model/test_actor_model.py

When to Use

  • Distributed systems — actors map naturally to network nodes (Erlang/OTP, Akka Cluster)
  • Game servers — each entity (player, NPC, room) as an independent actor
  • IoT — each device as an actor processing sensor events
  • Telecom — Erlang's origin: millions of concurrent call sessions
  • Chat systems — each conversation/room as an actor

When NOT to Use

  • Tight data coupling — if components need shared mutable state, message passing adds latency
  • Simple request-response — a function call is simpler than an actor roundtrip
  • Computation-heavy, no concurrency — actor overhead without concurrency benefit
  • Strong consistency — actors provide eventual consistency; use transactions for ACID

More Production Uses

  • Orleans (C#) — virtual actor ("grain") with RunMessageLoop dispatch at L980-L1012
  • Proto.Actor (Go) — minimal Actor interface with single Receive(c Context) method
  • Actix (Rust) — actor framework for Rust with typed messages
  • Microsoft DAPR — virtual actors for microservices
PatternRelationship
ObserverActors communicate via messages, similar to observer's publish/subscribe pattern
Event LoopEach actor processes its mailbox sequentially, like a single-threaded event loop
State MachineActor behavior often follows a state machine pattern for its internal logic

Challenge Questions

Q1: Actors communicate only via asynchronous messages, with no shared state or locks. A colleague claims "actors can't deadlock since there are no locks." Is this true?

Answer: Actors can still deadlock through circular message dependencies, even without any locks.

If Actor A sends a message to Actor B and waits for a response, while Actor B sends a message to Actor A and waits for a response, neither can process the other's message — both mailboxes contain an unprocessed message that requires the other to proceed. This is logically equivalent to a lock-based deadlock. The mitigation is to avoid synchronous request-reply patterns between actors, use timeouts on all message exchanges, or design message flows as DAGs (directed acyclic graphs) rather than cycles.

Q2: Your actor system has a fast producer actor sending 10,000 messages/second to a slow consumer actor that processes 100 messages/second. The consumer's mailbox grows unboundedly. How should an actor system handle this back pressure?

Answer: Bounded mailboxes with explicit back-pressure signals — when the mailbox is full, the sender must either drop messages, block, or receive a rejection signal.

Unbounded mailboxes are a common pitfall in actor systems — they trade memory for liveness, eventually causing OOM crashes. Akka offers BoundedMailbox which blocks senders when full, and flow-control via Akka Streams (reactive streams back-pressure). Erlang processes have unbounded mailboxes by design but rely on the OTP supervision tree to restart processes that consume too much memory. The architectural insight is that back-pressure is a system design concern, not just an actor concern — you need to decide at each producer-consumer boundary what happens when the consumer can't keep up.

Q3: An actor processing a payment message crashes mid-execution due to a bug. The payment was partially processed (funds debited but not credited). How does Erlang/OTP handle actor crashes without corrupting the system?

Answer: OTP's supervision tree restarts the crashed actor with fresh state — the key insight is that actor state is ephemeral and the source of truth lives elsewhere (database, message log).

Erlang's "let it crash" philosophy means actors don't try to recover from unexpected errors — they die, and a supervisor process restarts them. But this only works if the actor's side effects are either idempotent or transactional. For the payment case, the debit and credit should be wrapped in a database transaction, or the actor should use an outbox pattern: write the intent to a durable log first, then execute. If it crashes mid-execution, the restarted actor replays the log. The actor model isolates the crash (other actors are unaffected), but durability and consistency still require explicit design.

Q4: Erlang can run millions of actors (processes) on a single machine, each with only ~2KB of memory. The Go implementation in this doc uses goroutines with a channel mailbox. Could you run 1 million Go actors the same way?

Answer: Yes for the goroutine count (Go supports millions of goroutines), but each channel in the implementation allocates a buffer of 100 elements, and the combined channel overhead is significant.

A goroutine starts at 2KB stack (since Go 1.4), so 1 million goroutines cost ~2GB of stack memory alone. Each buffered channel adds its buffer size times the element size. Since Go 1.14, goroutines are asynchronously preempted via signals, so CPU-bound actors won't starve others. The deeper difference is Erlang's per-process garbage collection — each actor's GC pause is independent and microsecond-scale. Go's GC is global but concurrent, with STW pauses typically sub-millisecond (often under 100μs since Go 1.8+). The real tradeoff is that Erlang's per-process GC keeps pause impact localized, while Go's concurrent GC traverses the entire heap — meaningful at extreme actor counts. For truly massive actor counts, Erlang's BEAM VM was purpose-built for this; Go can approximate it but with different GC tradeoffs.

Released under the MIT License.