Actor Model: Erlang-Style Concurrency

Shared-state concurrency is a minefield. You've been there: a race condition slips through code review, manifests only under production load, and takes three engineers two days to diagnose. Locks...

Key Insights

  • The actor model eliminates shared-state concurrency bugs by enforcing isolation—each actor owns its state, and communication happens only through asynchronous message passing.
  • Erlang’s “let it crash” philosophy combined with supervision trees creates self-healing systems that recover from failures automatically, making fault tolerance a first-class architectural concern.
  • Actors excel at modeling distributed, stateful systems but add overhead that makes them a poor fit for CPU-bound computation or simple request-response applications.

Introduction: Why Actors?

Shared-state concurrency is a minefield. You’ve been there: a race condition slips through code review, manifests only under production load, and takes three engineers two days to diagnose. Locks seem like the answer until you hit your first deadlock. Fine-grained locking helps until the complexity becomes unmanageable. Read-write locks, lock-free data structures, memory barriers—the toolbox keeps growing, but so does the cognitive load.

Carl Hewitt proposed the actor model in 1973 as a fundamentally different approach. Instead of multiple threads sharing memory and coordinating access, actors are isolated computational units that communicate exclusively through message passing. No shared memory means no locks, no race conditions, no deadlocks.

Erlang, developed at Ericsson in the late 1980s for telecom switches, proved the model works at scale. Those switches needed 99.9999999% uptime (nine nines). They achieved it not by preventing failures but by embracing them—building systems that detect faults and recover automatically.

The Go proverb captures the philosophy: “Don’t communicate by sharing memory; share memory by communicating.” Actors take this literally.

Actor Model Fundamentals

An actor is an isolated unit of computation with three components: a mailbox for incoming messages, internal state that only it can access, and behavior that determines how it processes messages.

When an actor receives a message, it can do exactly three things:

  1. Create new actors to delegate work
  2. Send messages to actors it knows about
  3. Determine its next behavior (update its state or change how it handles subsequent messages)

That’s it. No reaching into another actor’s state. No global variables. No shared mutable data structures.

Here’s a minimal actor in Erlang—a counter that increments on command:

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

start() ->
    spawn(fun() -> loop(0) end).

loop(Count) ->
    receive
        {increment, Amount} ->
            loop(Count + Amount);
        {get_value, Caller} ->
            Caller ! {value, Count},
            loop(Count)
    end.

increment(Pid, Amount) ->
    Pid ! {increment, Amount}.

get_value(Pid) ->
    Pid ! {get_value, self()},
    receive
        {value, Count} -> Count
    after 5000 ->
        {error, timeout}
    end.

The spawn function creates a new actor (process in Erlang terminology) running the loop function. The receive block pattern-matches on incoming messages. The ! operator sends messages. Notice how loop calls itself recursively—this is how actors maintain state across messages without mutable variables.

Message Passing & Mailboxes

Actor messaging is asynchronous and fire-and-forget. When you send a message, you don’t wait for delivery confirmation. The message goes into the recipient’s mailbox, and execution continues immediately.

Each actor has exactly one mailbox—a queue of unprocessed messages. The actor pulls messages from this queue one at a time, processes each completely before moving to the next. This sequential processing within an actor eliminates internal concurrency concerns.

Erlang guarantees that messages sent from actor A to actor B arrive in the order sent. However, if actors A and C both send messages to B, there’s no guarantee about the interleaving. Design accordingly.

Here’s a request-response pattern between a client and a key-value store actor:

-module(kv_store).
-export([start/0, put/3, get/2]).

start() ->
    spawn(fun() -> loop(#{}) end).

loop(Store) ->
    receive
        {put, Key, Value, Caller} ->
            NewStore = maps:put(Key, Value, Store),
            Caller ! {ok, Key},
            loop(NewStore);
        {get, Key, Caller} ->
            case maps:find(Key, Store) of
                {ok, Value} -> Caller ! {ok, Value};
                error -> Caller ! {error, not_found}
            end,
            loop(Store);
        {delete, Key, Caller} ->
            NewStore = maps:remove(Key, Store),
            Caller ! {ok, deleted},
            loop(NewStore)
    end.

put(Pid, Key, Value) ->
    Pid ! {put, Key, Value, self()},
    receive
        {ok, Key} -> ok
    after 5000 ->
        {error, timeout}
    end.

get(Pid, Key) ->
    Pid ! {get, Key, self()},
    receive
        Response -> Response
    after 5000 ->
        {error, timeout}
    end.

The caller includes self() (its own process ID) in the message so the store knows where to send the response. This explicit reply-to pattern is fundamental—there’s no implicit return channel.

Supervision Trees & Fault Tolerance

Erlang’s radical insight: instead of preventing crashes, make them cheap and recoverable. An actor crash affects only that actor. Its supervisor detects the failure and restarts it according to a defined strategy.

This “let it crash” philosophy sounds reckless until you realize the alternative. Defensive programming—checking every possible error condition, handling every edge case—produces bloated code that still fails in unexpected ways. Erlang says: write the happy path, let supervisors handle the rest.

Supervision trees organize actors hierarchically. Supervisors monitor workers (or other supervisors), and when a child dies, the supervisor decides what to do:

  • one_for_one: Restart only the failed child
  • one_for_all: Restart all children if one fails
  • rest_for_one: Restart the failed child and all children started after it

Here’s a supervision tree in Elixir (Erlang’s modern cousin) for a chat application:

defmodule Chat.Application do
  use Application

  def start(_type, _args) do
    children = [
      {Registry, keys: :unique, name: Chat.Registry},
      {DynamicSupervisor, name: Chat.RoomSupervisor, strategy: :one_for_one},
      {Chat.ConnectionManager, []}
    ]

    opts = [strategy: :one_for_one, name: Chat.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

defmodule Chat.Room do
  use GenServer

  def start_link(room_id) do
    GenServer.start_link(__MODULE__, room_id, name: via_tuple(room_id))
  end

  def init(room_id) do
    {:ok, %{room_id: room_id, members: MapSet.new(), messages: []}}
  end

  def handle_cast({:join, user_id}, state) do
    {:noreply, %{state | members: MapSet.put(state.members, user_id)}}
  end

  def handle_cast({:message, user_id, text}, state) do
    message = %{from: user_id, text: text, timestamp: DateTime.utc_now()}
    broadcast(state.members, message)
    {:noreply, %{state | messages: [message | state.messages]}}
  end

  defp via_tuple(room_id), do: {:via, Registry, {Chat.Registry, room_id}}
  defp broadcast(members, message) do
    Enum.each(members, fn user_id ->
      # Send to each connected user's process
      send(user_id, {:new_message, message})
    end)
  end
end

If a chat room crashes (malformed message, unexpected state), the DynamicSupervisor restarts it. Users reconnect, the room reinitializes, and the system continues. No manual intervention, no 3 AM pages.

Actor Model in Other Languages

The actor model isn’t Erlang-exclusive. Several mature implementations exist:

Akka (Scala/Java) brings actors to the JVM with a rich feature set including clustering, persistence, and streams. It’s battle-tested at LinkedIn, PayPal, and countless others.

Orleans (.NET) from Microsoft introduces “virtual actors” that are automatically instantiated and garbage collected. You address actors by identity; the runtime handles placement.

Pony is an actor-based language with a novel capability system that guarantees data-race freedom at compile time.

Ray (Python) provides actor abstractions for distributed computing, popular in ML pipelines.

Here’s the counter example in Akka for comparison:

import akka.actor.{Actor, ActorSystem, Props}

class Counter extends Actor {
  var count = 0

  def receive: Receive = {
    case Increment(amount) =>
      count += amount
    case GetValue =>
      sender() ! Value(count)
  }
}

case class Increment(amount: Int)
case object GetValue
case class Value(count: Int)

// Usage
val system = ActorSystem("counter-system")
val counter = system.actorOf(Props[Counter], "counter")

counter ! Increment(5)
counter ! Increment(3)

import akka.pattern.ask
import scala.concurrent.duration._
implicit val timeout: akka.util.Timeout = 5.seconds

val future = (counter ? GetValue).mapTo[Value]

The concepts translate directly: message classes instead of tuples, sender() instead of explicit reply-to, ! for fire-and-forget, ? for request-response. Akka adds typed actors, persistence, and cluster sharding that Erlang handles differently through OTP behaviors.

When to Use (and Avoid) Actors

Actors shine in specific domains:

Distributed systems: Actor location transparency means the same code works whether actors are local or remote. Akka Cluster and Erlang’s distribution protocol handle the networking.

Stateful services: Each actor encapsulates state naturally. A user session, a game room, a device connection—these map cleanly to actors.

Real-time applications: Chat, gaming, IoT, live dashboards. The message-passing model handles concurrent connections elegantly.

Fault-tolerant systems: When uptime matters more than consistency, supervision trees provide automatic recovery.

Actors are a poor fit elsewhere:

CPU-bound computation: Actor overhead (mailbox management, scheduling) hurts throughput for number-crunching. Use parallel collections or work-stealing pools instead.

Tight latency requirements: Mailbox queuing adds unpredictable latency. For sub-millisecond response times, direct function calls beat message passing.

Simple CRUD applications: If your service is stateless request-response over a database, actors add complexity without benefit. A thread pool and connection pool work fine.

Watch for these pitfalls:

Mailbox overflow: A slow actor receiving messages faster than it processes them will eventually exhaust memory. Implement backpressure or bounded mailboxes.

Actor proliferation: Creating an actor per request sounds elegant but creates scheduling overhead. Batch work or use pooled actors for high-throughput scenarios.

Synchronous thinking: Wrapping every message send in a blocking wait defeats the purpose. Embrace asynchronous workflows.

Conclusion

The actor model requires a mental shift from “how do I protect shared data” to “how do I design message flows between isolated components.” This shift pays dividends in systems that are inherently concurrent, distributed, or failure-prone.

Start with Elixir if you’re new to actors—it has modern syntax, excellent tooling, and the full power of the Erlang VM. Work through the official “Getting Started” guide, then read “Designing for Scalability with Erlang/OTP” by Cesarini and Vinoski for production patterns.

The actor model won’t solve every concurrency problem. But for the problems it fits, it eliminates entire categories of bugs that plague traditional approaches. That’s worth the paradigm shift.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.