Skip to content

Actors and Message Passing

Confining mutable state inside single-threaded actors that communicate only by messages — the model behind event loops, and where it stops helping.

Advanced18 minUpdated 8 August 2026

Actors take the opposite approach to locking. Instead of protecting shared state with mutual exclusion, they remove sharing entirely: each actor owns its state privately, processes one message at a time, and communicates only by sending messages.

Nothing is shared, so there is nothing to lock, and the whole category of race conditions, deadlocks and visibility problems does not arise. What replaces it is a different set of problems — ordering, backpressure and failure — which is the honest trade this topic is about.

The model

Conceptual diagrammessagemessagemessagenever shares state directlyActor Aprivate stateActor BmailboxActor Cprivate stateActor Bprocesses onemessage at a timeActor D mailbox
Diagram, as text
  • Actor A private state leads to Actor B mailbox (message)
  • Actor C private state leads to Actor B mailbox (message)
  • Actor B mailbox leads to Actor B processes one message at a time
  • Actor B processes one message at a time leads to Actor D mailbox (message)
  • Actor B processes one message at a time leads to Actor A private state (never shares state directly)

Three rules define it:

  • State is private. No other actor can read or write it — not with a lock, not at all.
  • Messages are the only interface. Sending is asynchronous; the sender does not wait.
  • One message at a time. An actor's handler runs to completion before the next message, so within an actor you can write ordinary single-threaded code with no synchronisation whatsoever.

That third property is the payoff. Complex state transitions that would need careful locking become plain sequential code, because the actor is a serialisation point by construction.

You already use it

A JavaScript event loop is a single actor. One message at a time, private state, no shared memory — which is precisely why front-end and Node code has almost no race conditions of the locking kind.

Web workers and worker threads are actors: separate memory, communication by message passing.

A single-threaded service consuming from a partition — one consumer per partition, processing sequentially — is the actor model applied at system scale, and it is why partitioned log consumers are so much easier to reason about than shared-database workers.

A database connection handling one transaction at a time is the same idea again.

The pattern is far more common than the word "actor" suggests; recognising it lets you apply the reasoning.

What replaces the lock problems

Message ordering is only guaranteed pairwise, if at all. Messages from A to B usually arrive in order; messages from A and C to B interleave arbitrarily. Logic that assumes a global order is wrong, and it will be wrong intermittently.

Mailboxes are queues, so they need bounds. An unbounded mailbox has exactly the failure described under producer–consumer: a slow actor accumulates messages until memory is exhausted. Bounded mailboxes mean sending can block or fail, which reintroduces backpressure as an explicit design concern.

Request–response needs correlation. Sending is one-way, so a reply is a separate message that must be matched to its request, with a timeout for the reply that never arrives. What was a method call becomes a small protocol.

Failure is asynchronous. An actor that throws does not fail its sender. Something must supervise: restart it, escalate, or discard it — and decide what happens to the messages already in its mailbox.

Deadlock does not disappear, it changes shape. Two actors each waiting for the other's reply is a deadlock, just built from message waits rather than locks. It is easier to detect — the timeouts fire — and just as real.

Granularity is the design decision

Too coarse — one actor for all orders — and it is a global lock with extra steps. Every order serialises behind every other, and throughput is capped at one core.

Too fine — one actor per line item — and messaging overhead dominates while operations that need several items become distributed protocols.

The useful rule: one actor per consistency boundary. An actor per order gives serialisability within an order and parallelism across orders, which is nearly always the shape you want. This is the same reasoning as choosing an aggregate boundary in domain modelling, and the same reasoning as choosing a partition key — a decision that recurs at three different scales.

The consequence to accept: operations spanning several actors are not atomic. Transferring between two accounts, each its own actor, needs a protocol with compensation, not a transaction. If your domain has many such operations, the boundaries are drawn wrongly, or actors are the wrong model.

Check your understanding

Common mistakes

Blocking inside a handler. Everything behind it in the mailbox waits.

Unbounded mailboxes. A slow actor accumulates messages until memory is exhausted.

Assuming global message ordering. Only pairwise ordering is typically guaranteed, and code relying on more fails intermittently.

One actor for everything. A global lock with messaging overhead and no parallelism.

Request–response without timeouts. A reply that never arrives leaves a correlation entry and a caller waiting forever.

No supervision strategy. A crashed actor and its mailbox contents vanish silently.

Expecting transactions across actors. Multi-actor operations need a protocol with compensation.

Sending mutable objects as messages. If the sender keeps a reference and mutates it, the isolation the model depends on is gone.

Practice

Design an actor-based system for a multiplayer game server handling rooms of up to 8 players. State the actor boundaries, the message flows, and what happens when an actor crashes.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Draw the boundary where state must be consistent, then check what operations cross it.

Show a strong answer

Boundaries: one actor per room, one per connected player session, one supervisor per room set.

  • The room actor owns the game state — positions, scores, the tick. Every action affecting the game goes through it, which makes the game loop plain sequential code with no locks. This is the boundary that matters: game state must be consistent within a room and is entirely independent across rooms, so it is the consistency boundary exactly.
  • A session actor per connection owns the socket, serialisation and the player's connection state. Keeping this separate means a slow or disconnecting client cannot stall the room — a session's problems stay in the session's mailbox.
  • Not one actor per player entity. Player state is only meaningful inside a room, and separating it would make every game tick a distributed protocol across nine actors.

Message flows:

  • Session → room: player input, joins, leaves. Fire-and-forget; the room applies them on its next tick.
  • Room → sessions: state updates, broadcast after each tick. Also one-way.
  • Room → matchmaker: room full, room empty. These are the only cross-boundary messages, and there are deliberately few.
  • Input is applied at tick boundaries, not on arrival, which sidesteps the ordering question entirely: messages from eight sessions interleave arbitrarily, so any design that applies them immediately has made the game depend on mailbox interleaving. Collecting per tick and applying in a defined order — by sequence number, or by a documented rule — makes the result reproducible.

Mailbox bounds: the room's mailbox must be bounded. Eight clients sending faster than the tick rate would otherwise grow it without limit. When full I would drop the oldest input from that player rather than block — for a game, stale input is worthless and fresh input matters, which is the drop-oldest policy being correct rather than convenient.

When an actor crashes:

  • A session crashes: the supervisor restarts it, or the connection is closed and the room is told the player disconnected. The room continues — one player's failure must never take down seven others, and this is the main reason sessions are separate actors.
  • A room crashes: this loses live game state, and the honest answer is that this is unrecoverable unless state was checkpointed. I would checkpoint room state periodically to a store and restart from the last checkpoint, accepting a small rollback, because the alternative — no recovery — ends eight players' game. Whether that trade is right is a product decision and should be made explicitly rather than by omission.
  • Messages in a crashed actor's mailbox are lost. For player input that is acceptable; for a "player joined" message it is not, since the player would be connected to a room that does not know about them. Joins should therefore be acknowledged and retried, not fire-and-forget — the one place in this design where I would pay for a request–response protocol.

What the supervisor must decide, explicitly: restart limits. An actor crashing in a loop must escalate rather than restart forever, or a single malformed input becomes an infinite restart cycle.

A team wants to replace all locking in a shared in-memory cache with actors, arguing it will remove their race conditions. Evaluate.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Ask what the access pattern is, and what a cache read costs under each model.

Show a strong answer

The claim about race conditions is true, and it is the wrong trade for this particular structure.

What they would gain: the races genuinely disappear. Cache state becomes private to one actor, updates are sequential, and the class of bug they are complaining about cannot occur. If their current implementation has subtle bugs around eviction racing with insertion, those go away by construction.

What they would lose, and why it is decisive for a cache:

  • Every read becomes a message round trip. A cache read is currently a hash lookup — tens of nanoseconds. Through an actor it is an enqueue, a context switch, a dequeue and a reply, which is microseconds at best. A cache exists to be faster than the thing it caches; making reads three orders of magnitude slower can genuinely make it slower than the database it fronts.
  • A single cache actor serialises all access. Caches are overwhelmingly read-heavy and reads do not conflict with each other at all. Forcing them through one mailbox converts a workload with no contention into one with total contention — the opposite of what they want.
  • Reads become asynchronous, so every call site changes shape and must handle a timeout for a lookup that used to be a guaranteed return.

What I would propose instead, in order:

  • A concurrent map designed for this access pattern. Lock-free or striped-lock reads with no coordination between them are what this data structure needs, and it is almost certainly available in the standard library.
  • Copy-on-write if the cache is read-heavy and written rarely — configuration-like data. Readers get an immutable snapshot with no synchronisation at all, which is faster than either locks or actors.
  • Immutable cache entries regardless of the container, so a reader holding a value cannot be affected by an update. This alone removes a common source of the races they are describing, at almost no cost.

Where actors would be right in the same system: the parts of the cache with genuinely complex, contended state transitions — eviction bookkeeping, refresh coordination, ensuring only one thread recomputes a missing entry. Those are low-frequency and stateful, which is exactly the actor's shape. Reads through a fast concurrent structure, coordination through an actor is a better design than either extreme, and it is worth pointing out that their instinct is right for one half of the system.

The general lesson I would draw for them: actors eliminate races by serialising, and serialising is precisely wrong for workloads whose operations do not conflict. Choose the model from the access pattern, not from the bug you most recently fixed.

Key takeaways

  • Actors remove sharing rather than protecting it: private state, messages only, one message at a time.
  • The payoff is that handler code is ordinary sequential code — the actor is a serialisation point by construction.
  • The problems that replace locking: pairwise-only ordering, mailbox bounds, request–response correlation, asynchronous failure, and deadlock in a new shape.
  • Never block inside a handler — everything behind it in the mailbox waits.
  • Granularity is the design decision. One actor per consistency boundary; too coarse is a global lock, too fine is a distributed protocol.
  • Operations spanning actors are not atomic and need compensation.
  • You already use it: event loops, worker threads, one consumer per partition.
  • Actors serialise, so they are wrong for workloads whose operations do not conflict — read-heavy caches want concurrent structures or copy-on-write.

Further reading

  • Carl Hewitt, Peter Bishop and Richard Steiger, "A Universal Modular Actor Formalism for Artificial Intelligence" (IJCAI, 1973).
  • Gul Agha, Actors: A Model of Concurrent Computation in Distributed Systems (MIT Press, 1986).
  • Joe Armstrong, "Making Reliable Distributed Systems in the Presence of Software Errors" (doctoral thesis, KTH, 2003) — supervision and let-it-crash.
  • Brian Goetz and others, Java Concurrency in Practice (Addison-Wesley, 2006) — confinement as a thread-safety strategy.
  • Vaughn Vernon, Reactive Messaging Patterns with the Actor Model (Addison-Wesley, 2015).

Check yourself

3 questions in this lesson. Answer each once to complete it.

No problem practices this lesson yet. Browse the practice problems

Next lessonLayered and Hexagonal Architecture
Lessons in this track