The Actor Model, Explained

CareerEngineering
Share on LinkedIn

Shared mutable state plus locks is the default concurrency model—and the default source of heisenbugs. The actor model offers a different contract: no shared state between workers, only asynchronous messages. Each actor processes its mailbox one message at a time, like a single-threaded event loop with an address. It is not magic parallelism; it is disciplined isolation that scales mentally when your system has thousands of independent sessions.

Core principles

  1. Isolation — actor state is private; nothing else reads or writes it directly
  2. Message passing — communication via immutable messages sent to an address
  3. Sequential processing — one message handler runs at a time per actor
  4. Location transparency — senders do not care if the actor is local or remote

No locks inside the actor because no concurrent access to its state exists.

Minimal actor pseudocode

// Conceptual—not a production framework API
class CounterActor : Actor {
    private var count = 0

    override fun receive(msg: Message) = when (msg) {
        is Increment -> count++
        is GetCount -> sender.reply(count)
        is Reset -> count = 0
    }
}

// Usage
actorRef.send(Increment)
val future = actorRef.ask(GetCount)  // async reply

receive runs to completion before the next message—no interleaved mutations.

Mailboxes and backpressure

Messages queue in the mailbox. Unbounded mailboxes risk OOM under overload—production systems use bounded mailboxes with drop, block, or sender-side backpressure strategies (see separate article).

Priority mailboxes exist but complicate fairness—default FIFO is predictable.

Erlang/OTP: the reference implementation

Erlang processes are lightweight actors (kilobytes each, millions per node):

-module(counter).
-export([start/0, loop/1]).

start() ->
    spawn(?MODULE, loop, [0]).

loop(Count) ->
    receive
        increment -> loop(Count + 1);
        {get, From} -> From ! Count, loop(Count)
    end.

OTP adds supervision trees:

init([]) ->
    {ok, {{one_for_one, 5, 10}, [
        {worker1, {worker, start_link, []}, permanent, 5000, worker, []}
    ]}}.

one_for_one restarts only the crashed child; 5 in 10 limits restart storms.

Akka on the JVM

class Counter extends Actor {
  var count = 0
  def receive = {
    case Increment => count += 1
    case GetCount    => sender() ! count
  }
}

val counter = system.actorOf(Props[Counter](), "counter")
counter ! Increment

Akka Cluster shards actors across nodes by key—useful for stateful entities at scale (bank accounts, user sessions).

Actors vs threads vs coroutines

Model Isolation Overhead Best for
Threads + locks Manual OS thread cost Shared mutable caches
Coroutines Cooperative Low Async I/O, structured concurrency
Actors Message-only Medium Stateful entities, fault domains

Kotlin coroutines are not actors unless you enforce single-threaded dispatchers per entity. Pattern:

class EntityActor(scope: CoroutineScope) {
    private val channel = Channel<Event>(Channel.UNLIMITED)

    init {
        scope.launch(Dispatchers.Default.limitedParallelism(1)) {
            for (event in channel) handle(event)
        }
    }

    fun send(event: Event) { channel.trySend(event) }
}

One coroutine, one mailbox—actor discipline with coroutine syntax.

When actors hurt

CPU parallel pipelines. Splitting numeric work across actors adds message overhead without gaining isolation benefits. Use parallel streams or worker pools.

Request-response hot paths. Synchronous ask patterns with timeouts add latency vs direct calls. Actors excel at async fire-and-forget and long-lived state.

Distributed tracing complexity. Message chains across actors require correlation IDs in every message for observability.

Design guidelines

Keep messages small and immutable—send IDs, not object graphs.

One concern per actor—god actors with giant when blocks become unmaintainable.

Document message contracts like API schemas—version messages for evolution.

Prefer tell over ask; ask only when reply is required and handle timeout.

Actor supervision hierarchies

Supervisors restart failed actors without crashing the system:

/userService          ← top-level supervisor
  ├── /sessionManager ← restarts on SessionActor failure
  │     ├── /session-abc123
  │     └── /session-def456
  └── /paymentGateway ← restarts on PaymentActor failure
// Akka-style supervision in Kotlin (conceptual)
sealed class SupervisorStrategy {
    object Restart extends SupervisorStrategy()   // restart actor, clear state
    object Resume extends SupervisorStrategy()    // continue with current state
    object Stop extends SupervisorStrategy()      // terminate permanently
    object Escalate extends SupervisorStrategy()  // propagate to parent supervisor
}

One actor crash shouldn't cascade. Session actor failure restarts that session — not the entire user service.

Message mailbox backpressure

Unbounded mailboxes cause OOM when producer outpaces consumer:

// Bounded mailbox with backpressure
class BoundedActor<T>(capacity: Int = 1000, handler: suspend (T) -> Unit) {
    private val mailbox = Channel<T>(capacity)

    suspend fun send(msg: T) {
        mailbox.send(msg)  // suspends when full — backpressure
    }

    init {
        CoroutineScope(Dispatchers.Default).launch {
            for (msg in mailbox) handler(msg)
        }
    }
}

Bounded channel as mailbox — producer suspends when mailbox full. Prefer tell (fire-and-forget) over ask (request-reply) to avoid reply mailbox buildup.

Distributed actors with Orleans

Virtual actors (grains) for cloud-native actor model:

// Microsoft Orleans grain
public interface IUserGrain : IGrainWithStringKey {
    Task<OrderHistory> GetOrderHistory();
    Task AddOrder(Order order);
}

public class UserGrain : Grain, IUserGrain {
    private List<Order> _orders = new();

    public Task AddOrder(Order order) {
        _orders.Add(order);  // state persisted automatically
        return Task.CompletedTask;
    }
}

Orleans handles activation, deactivation, placement, and persistence. Actor location transparent — no manual shard routing.

Failure modes

Production checklist

Resources

Frequently asked questions

What is an actor in concurrent programming?

An actor is a unit of computation with private state, a mailbox queue, and a behavior function that processes one message at a time. External code sends messages asynchronously; the actor handles them sequentially, eliminating locks on internal state. Actors communicate only via messages—no shared mutable variables between actors.

When should I use the actor model?

Actors fit event-driven systems with many independent entities—chat sessions, game entities, IoT device handlers, or sharded workers. They simplify reasoning about concurrency when shared-memory locks become error-prone. They are less ideal for CPU-bound parallel numeric work better served by structured fork-join or SIMD.

How does actor supervision work?

Supervisors are actors that spawn child actors and define restart strategies—restart on failure, escalate to parent, or stop. Erlang OTP popularized 'let it crash': isolate failure to one actor, restart with clean state, preserve system availability. Akka and Orleans provide similar hierarchy models.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →