Skip to content
Software Atlas

Actors vs CSP

Two ways to share by communicating: actors with private state and a mailbox (Erlang, Akka, Web Workers) vs anonymous workers joined by channels (Go). How each works, how to build an actor in Go and JavaScript, supervision, mailbox overflow, and when to pick which.

⏱ ~35 min read12 sections8 interactive widgetsGo · JavaScript · Elixir
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

When several tasks run at the same time, the dangerous thing is a variable that more than one of them can change. Two tasks update it at once, and one update silently disappears (§02 shows exactly how). Two well-known approaches — actors and CSP — both start from one idea: don't share the variable at all; send messages instead.

In one sentenceAn actor is a worker with private data and its own inbox: you send a message to it, by name. CSP joins anonymous workers with pipes: you send a message into a pipe, and whoever is at the other end takes it.
1

Actors

An Actor = private state + a Mailbox + a rule for handling each message. Used by Erlang and Elixir (languages built around actors), Akka (an actor library for Java and Scala), Orleans (Microsoft's version for .NET) — and, in spirit, by browser Web Workers.
2

CSP

Goroutines + channels. Go's design is based on CSP. The channel has the name; the goroutines don't.
3

Same goal

Both follow Go's proverb: don't communicate by sharing memory; share memory by communicating. They differ in who gets addressed and whether the sender waits.

The map of this page

  • §02–§04 (level 1): the problem, the actor model, and how it differs from channels — with three things to click.
  • §05–§07 (level 2): build an actor in Go and in JavaScript, then see what Erlang and Akka add: supervisors that restart crashed actors.
  • §08–§10: what goes wrong in production (overflowing mailboxes, message order, deadlocks), and how to choose.
  • §11–§12: quiz and cheat sheet.
Foundations§02

The problem: two writers, one variable

In one sentenceA variable that several tasks can change is the root of most concurrency bugs. There are three ways out: take turns with a lock, give the variable one owner that you send messages to (actors), or pass the value along so only its current holder uses it (CSP).

Take a bank account with 100 in it. Two goroutines each deposit 50 at the same moment. You expect 200. The trouble is that balance += 50 isn't one step for the computer — it's three: read the balance, add 50, write the result back. If both goroutines read before either one writes, both write 150, and one deposit vanishes.

Step through it, then flip the toggle to see the same job done by a single owner:

Two deposits, two designs
Step through a lost update, then the same job with a single owner.
Code
1balance := 100
2
3deposit := func() { // "balance += 50", spelled out
4 tmp := balance // 1. read
5 tmp = tmp + 50 // 2. add
6 balance = tmp // 3. write back
7}
8go deposit() // goroutine A
9go deposit() // goroutine B
goroutine A
started
goroutine B
started
balance (shared)
100
Two goroutines each deposit 50, so we expect 100 + 50 + 50 = 200. But balance += 50 is really three steps — read, add, write — and goroutines can run at the very same moment on different CPU cores. So the steps of A and B can interleave.
1 / 6

When two goroutines touch the same variable at the same time, at least one writes, and nothing coordinates them, it's called a Data race. Go even ships a tool to catch it: run your tests with go test -race.

Three ways out

ApproachThe ideaExamples
LockEveryone may touch the data, but only one at a time. Others wait their turn.sync.Mutex — see Channels §10
ActorOne worker owns the data. Others send it messages; it handles them one at a time.Erlang, Akka, the toggle above; §03 explains it
CSPThe data moves between workers through channels. Whoever holds it right now is the only one using it.Go pipelines — see Channels §06

A Lock keeps the data where it is and makes tasks queue up for it. The other two are both message passing: instead of reaching into shared memory, tasks send each other values. This page is about those two, and how they differ.

Foundations§03

The actor model: people with inboxes

In one sentenceAn actor owns some private data and has a mailbox. It takes one message out, handles it completely, then takes the next. That's the whole model.

The idea comes from Carl Hewitt in 1973. An Actor has three parts:

1

Private state

Data only this actor can read or change — e.g. an account's balance. No other code can reach it, so there's nothing to lock.
2

A mailbox

A queue of incoming messages, oldest first. Sending just drops a message in; the sender doesn't wait for it to be handled.
3

Behavior

The code that runs for each message: "on deposit, add to balance". It runs for one message at a time, start to finish.

What an actor may do with a message

While handling one message, an actor is allowed to do exactly three kinds of things:

  1. Change its own state — "balance is now 150".
  2. Send messages to actors it knows the address of — including a reply to whoever asked.
  3. Create new actors — e.g. a "bank" actor that opens a new account actor for each customer.

That's it. It cannot read another actor's state or call its methods. The only way to affect another actor is to send it a message, which that actor will handle in its own time.

Try it

Each card is an actor: its locked private state (🔒), the message it's handling right now, and its mailbox. Every tick (about one second), each actor handles one message. They all work at the same time — but each one handles its own messages strictly one after another.

An actor system, live
Four actors, each with private state and a mailbox. You send messages; each actor handles one message per tick. Pause to let mailboxes fill up.
@bankcreates accounts · handled 0
🔒 accounts opened 0
now handling
idle
mailbox (0) · oldest first
empty
@alicea bank account · handled 0
🔒 balance 100
now handling
idle
mailbox (0) · oldest first
empty
@boba bank account · handled 0
🔒 balance 50
now handling
idle
mailbox (0) · oldest first
empty
@auditkeeps a log · handled 0
🔒 0 lines · last: —
now handling
idle
mailbox (0) · oldest first
empty
Try: pause, press burst twice, then resume and watch @alice work through her mailbox one message at a time. Then try a transfer.

Things to notice:

  • Burst five deposits while paused. They don't collide — they wait in line, and the balance goes up by 10 five times.
  • A transfer is one message to alice. While handling it, alice changes her balance and sends a deposit to bob. Bob handles it later, on his own schedule. For a moment the money is "in the mail" — in neither balance. That's normal for actors: each actor's own state is always consistent, but the system as a whole catches up message by message.
  • Withdraw more than the balance: pick @bob (50) and press withdraw 30 twice. The first succeeds; the second is refused. The check ("is there enough?") and the change happen inside one message, so nothing can sneak in between them. With a shared variable, two withdrawals could both pass the check and overdraw the account — a cousin of the race in §02. Here it can't happen.
  • @bank: open account makes the bank actor create a brand-new actor.
  • Every change also sends a record to @audit. Nobody waits for the audit log; it catches up on its own.
Foundations§04

CSP in one paragraph — and the real difference

In one sentenceActors: you send to a worker, and the message waits in its mailbox. CSP: you send into a channel, and (by default) you wait until some worker takes it.

CSP in one paragraph. CSP (Tony Hoare, 1978) builds programs from independent workers that never share variables; they talk only through channels. In Go that means goroutines plus ch <- v and v := <-ch. An unbuffered channel stores nothing: the sender waits until a receiver takes the value, so the two meet — a handshake (called a rendezvous). A Buffered channel adds N slots so the sender can run ahead. Either way, the value you send is handed over: once sent, it's the receiver's to use — that's what "the data moves" meant in §02. The Channels topic teaches all of this in depth — this page only needs the basics.

The two differences that matter

1. What has the name. With actors, the worker has an address and you send to it: "Alice, do this". With CSP, the channel has the name and the workers are anonymous: you put a job on jobs, and any goroutine reading jobs might take it.

2. Whether the sender waits. An actor's mailbox is a queue, so sending returns immediately — the message waits, not you. (In Erlang and Akka the mailbox has no size limit by default; §08 shows why that's a mixed blessing.) An unbuffered channel makes the sender wait for a receiver. (A buffered channel is in between: it's a small mailbox with a size limit.)

Watch both with the same job — a fast producer, a slow receiver:

Same job, two styles
A fast producer sends 4 messages to a slow receiver (it needs 2 ticks per message). Left: an actor. Right: a Go channel.
Actor · send TO an addresssend(printer, msg)
producer
about to send…
@printer's mailbox
empty
@printer
idle
The mailbox absorbs the burst; the producer never waits.
CSP · send ON a channeljobs <- msg
producer
about to send…
channel jobs · cap 0
no storage — sender and receiver must meet
R1
idle
Unbuffered: each send waits for a receiver.
tick 0 · actor producer free at — · channel producer free at — (blocked 0 ticks)

Try 2 receivers: on the channel side, either receiver may take each value — the producer never named one. On the actor side nothing changes, because you addressed @printer. Then try buffer 3 and notice the channel starts to behave like a mailbox.

Side by side

ActorsCSP (Go channels)
You send to…an actor (its address)a channel; any receiver may take it
Does the sender wait?No — the message waits in the Mailbox (unless the mailbox has a size limit and is full, §08)Unbuffered: yes, until a receiver takes it. Buffered: only when full
Inboxes per workerExactly one mailboxAs many channels as you like, read with select (see the Go primer)
What it's best atThings with identity and state: an account, a chat roomFlows of data: pipelines, worker pools
Across machinesBuilt in for Erlang and Akka (§07)Within one program only
When a worker crashesSupervisors restart it (§07)Up to you
Practitioner§05

Building an actor in Go

In one sentenceIn Go, an actor is one goroutine that owns some state and loops over one inbox channel. To ask it something, send a request that carries its own reply channel.

Go has no actor keyword, but it has everything needed: a Goroutine to run the actor, a Buffered channel as its Mailbox, and a for loop that takes one message at a time. Callers make two kinds of requests: a tell ("deposit this", send and move on) and an ask ("what's the balance?", wait for a reply). Here's the account from §03 as a complete program:

account.go
package main

import (
    "context"
    "fmt"
    "sync"
)

// The messages this actor understands.
type deposit struct{ amount int }
type getBalance struct{ reply chan int } // carries its own "return address"

type Account struct {
    inbox chan any // the mailbox; its channel is the actor's address
}

func NewAccount(ctx context.Context) *Account {
    a := &Account{inbox: make(chan any, 64)} // up to 64 messages can wait
    go a.loop(ctx)                            // start the actor's goroutine
    return a
}

// loop IS the actor. Only this goroutine ever touches bal.
func (a *Account) loop(ctx context.Context) {
    bal := 0 // private state
    for {
        select {
        case msg := <-a.inbox: // take ONE message…
            switch m := msg.(type) { // …see what kind it is…
            case deposit:
                bal += m.amount // …and handle it completely
            case getBalance:
                m.reply <- bal
            }
        case <-ctx.Done(): // someone asked the actor to stop
            return
        }
    }
}

// Friendly methods, so callers never build messages by hand.
func (a *Account) Deposit(n int) { a.inbox <- deposit{n} } // "tell": don't wait for a result

func (a *Account) Balance() int { // "ask": wait for the reply
    reply := make(chan int, 1)
    a.inbox <- getBalance{reply}
    return <-reply
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // stops the actor when main returns

    acct := NewAccount(ctx)
    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() { defer wg.Done(); acct.Deposit(1) }() // 100 goroutines, no lock
    }
    wg.Wait()                   // all 100 deposits are now in the mailbox…
    fmt.Println(acct.Balance()) // …and getBalance lines up behind them: 100
}

A few details carry the whole design:

  • The inbox channel is the actor's address. Whoever holds acct (and so acct.inbox) can send it messages. Nobody can reach bal.
  • The buffer is the mailbox size. If 64 messages are already waiting, Deposit blocks until the actor catches up. That's Backpressure — a feature Erlang and Akka mailboxes don't give you by default (§08).
  • ctx.Done() stops the actor. Without it, the goroutine would wait on the inbox forever after everyone stops using it — a goroutine leak (see Channels §08). Note the flip side: once the actor has stopped, any messages still queued are never handled, and a later Deposit blocks forever as soon as the buffer is full. §10 (pitfall #7) covers how to guard against that.

Asking a question: the reply channel

Deposit is a tell: drop the message and move on. Balance is an ask: the caller needs an answer, but it can't read bal itself. So it makes a fresh channel, puts it inside the request, and waits on it. Step through it:

Asking an actor a question
Request/reply with a reply channel, step by step.
Code
1func (a *Account) Balance() int {
2 reply := make(chan int, 1) // my private "return address"
3 a.inbox <- getBalance{reply} // the request carries it
4 return <-reply // wait for the answer
5}
6
7// inside the account's loop:
8case getBalance:
9 m.reply <- bal // answer on the caller's channel
caller goroutine
calls a.Balance()
account's mailbox
deposit{50} · from someone else
account goroutine (owns bal)
bal = 100
busy with an earlier message
Someone has already sent a deposit{50}; it's waiting in the mailbox. Now our caller wants to know the balance. It can't read bal — that variable belongs to the account goroutine — so it has to ask.
1 / 7

Two choices in that code matter:

  1. make(chan int, 1) — one slot. The actor's m.reply <- bal can always complete, even if the caller already gave up and left. With an unbuffered reply channel, a caller that stopped waiting (you'll see such a caller in the timeout version below) would leave the actor stuck on that send — and a stuck actor stops reading its whole mailbox.
  2. The answer reflects the mailbox order. getBalance was handled after the deposit that arrived before it, so the caller sees 150. Each answer is consistent with every message ahead of it in the queue.

The production version: never wait forever

The simple Balance() waits forever if the actor has stopped or is overloaded. Real code passes a context.Context and gives up when it's cancelled or times out — the same thing Akka calls an ask timeout:

balance with a timeout
func (a *Account) Balance(ctx context.Context) (int, error) {
    reply := make(chan int, 1)
    select {
    case a.inbox <- getBalance{reply}: // the request got into the mailbox
    case <-ctx.Done():
        return 0, ctx.Err() // mailbox full for too long, or caller cancelled
    }
    select {
    case v := <-reply:
        return v, nil
    case <-ctx.Done():
        return 0, ctx.Err() // gave up waiting; the 1-slot reply channel keeps the actor safe
    }
}

// Caller: give it at most 200 ms.
//   ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
//   defer cancel()
//   bal, err := acct.Balance(ctx)
Practitioner§06

Actors in JavaScript: a mailbox class and Web Workers

In one sentenceIn JavaScript an actor is a queue plus a loop that awaits one handler at a time. And every Web Worker is already an actor: private memory, a message queue, and postMessage as its address.

JavaScript runs your code on one Thread, so you might think it doesn't need actors. Without any await, that's true: two pieces of JS never run at the same instant. But an async handler pauses at every await — and while it's paused, another call can start and read the same state. That's the race in Async / Await §07. A mailbox fixes it by refusing to start the next message until the current one is completely done:

An async handler, called twice
deposit(a); deposit(b) — no mailbox → final balance 50 ✗
deposit a
reads 0
await auditLog
writes 50
deposit b
reads 0
await auditLog
writes 50
actor.send(a); actor.send(b) — with a mailbox → final balance 100 ✓
deposit a
reads 0
await
writes 50
deposit b
queued in the mailbox
reads 50
await
writes 100
Two deposits of 50 into a balance that starts at 0. Striped = paused at await auditLog.write(); amber = waiting in the mailbox. Without a mailbox, deposit b starts while a is paused, both read balance = 0, and the final balance is 50 instead of 100 (the race from the Async/Await topic). With a mailbox, b waits in the queue until a is completely done, so b reads 50 and writes 100.

The mailbox class

actor.js
class Actor {
  #mailbox = [];     // waiting messages, oldest first
  #running = false;  // is the loop below already working?

  constructor(handler) { this.handler = handler; } // handler(msg) may be async

  send(msg) {                 // "tell": queue it and return at once
    this.#mailbox.push(msg);
    if (!this.#running) {                 // no loop yet? schedule one
      this.#running = true;
      queueMicrotask(() => this.#drain()); // runs right after the sender's current code
    }
  }

  ask(msg) {                  // "ask": the promise is the reply channel
    return new Promise((resolve, reject) => this.send({ ...msg, resolve, reject }));
  }

  async #drain() {
    while (this.#mailbox.length) {
      const msg = this.#mailbox.shift();          // take the oldest message
      try {
        await this.handler(msg);                  // ← finish it completely before the next
      } catch (err) {
        if (msg.reject) msg.reject(err);          // an ask gets the error back
        else console.error("actor failed on", msg, err);
      }
    }
    this.#running = false;
  }
}

Here is the loop at work, with a handler that pauses at an await:

Inside the mailbox loop
Two sends to an actor whose handler awaits. Watch the mailbox, the #running flag and the console.
Code
1send(msg) {
2 this.#mailbox.push(msg);
3 if (!this.#running) {
4 this.#running = true;
5 queueMicrotask(() => this.#drain());
6 }
7}
8async #drain() {
9 while (this.#mailbox.length) {
10 const msg = this.#mailbox.shift();
11 await this.handler(msg);
12 }
13 this.#running = false;
14}
15
16// handler: log "start", await sleep(10), log "end"
17actor.send("a");
18actor.send("b");
#mailbox
✉ "a"
#running · what's executing
#running = false
running: your script
console
nothing logged yet
send("a") pushes the message into the mailbox. Nothing else has happened yet.
1 / 7

Why is #running safe without a lock? Because the only gaps where other code can run are the awaits. The final check while (this.#mailbox.length) and this.#running = false run together with no await between them, so a send can never slip in and find the loop "half-stopped" — it either sees #running === true and its message gets picked up by the loop, or sees false and schedules a new one.

Here is the same account as §05 in both languages. Switch tabs to compare:

function makeAccount() {
  let balance = 0;                       // private: only the handler can see it
  return new Actor(async (msg) => {
    switch (msg.type) {
      case "deposit":
        await auditLog.write(msg);       // some async API: a pause — but no other
                                         // message can start until this one finishes
        balance += msg.amount;
        break;
      case "balance":
        msg.resolve(balance);            // answer the ask
        break;
    }
  });
}

const acct = makeAccount();
acct.send({ type: "deposit", amount: 50 });      // tell
console.log(await acct.ask({ type: "balance" })); // ask → 50
// (await at the top level like this works in ES modules: .mjs files or <script type="module">)

The balance query returns 50, not 0, for the same reason as in §05: it sits behind the deposit in the mailbox, and the deposit's await finishes before the next message starts.

Web Workers: actors built into the platform

A Web Worker (or a Worker thread in Node) runs JavaScript on another thread with its own memory. The page can't touch the worker's variables, and the worker can't touch the page's. They communicate only with postMessage, and each side handles incoming messages one at a time through its own Event loop. Private state, a mailbox, one message at a time — that's an actor.

const counter = new Worker(new URL("./counter.js", import.meta.url), { type: "module" });

counter.onmessage = (e) => console.log("total is", e.data); // replies arrive here
counter.postMessage({ type: "add", n: 5 });  // tell: returns at once
counter.postMessage({ type: "add", n: 2 });
counter.postMessage({ type: "get" });        // → "total is 7"

postMessage copies the data with the Structured clone algorithm, so the worker gets its own copy — exactly like Erlang, where messages are copied between processes. (The exceptions are transferable objects such as an ArrayBuffer that you list in the transfer list — postMessage(buf, [buf]) — which are moved instead of copied, and SharedArrayBuffer, which really is shared — and brings back every problem from §02.)

Practitioner§07

Erlang, Elixir and Akka: supervisors and “let it crash”

In one sentenceLanguages built around actors add one big idea on top: supervisors — actors whose job is to restart other actors when they crash. That lets you write the happy path and recover from the rest.

Erlang was built at Ericsson in the 1980s for telephone switches that must never go down. Elixir (2012) is a friendlier language on the same virtual machine, the BEAM. Both call their actors processes — not operating-system processes, but tiny ones managed by the BEAM, starting at a few kilobytes each. A single machine can run millions. Akka brings the same ideas to the JVM (Scala/Java); since its 2022 license change, Apache Pekko is its open-source fork.

An actor in Elixir

Erlang-family languages have actors built in: spawn creates one, send drops a message in its mailbox, receive takes the next message that matches a pattern.

defmodule Counter do
  def loop(count) do                 # the actor's state is the argument
    receive do                       # take the next matching message
      {:add, n} ->
        loop(count + n)              # "change state" = loop again with a new value
      {:get, from} ->
        send(from, {:count, count})  # reply to whoever asked
        loop(count)
    end
  end
end

pid = spawn(fn -> Counter.loop(0) end)  # create an actor; pid is its address
send(pid, {:add, 5})                    # tell: returns immediately
send(pid, {:get, self()})               # ask: "reply to me" (self() = my own pid)

receive do
  {:count, c} -> IO.puts(c)             # 5
end

In real Elixir code you rarely write receive by hand. GenServer wraps this loop for you: GenServer.cast is a tell, and GenServer.call is an ask that waits for the reply — with a 5-second timeout by default, so a stuck actor can't hang the caller forever.

Supervisors and "let it crash"

Here's the twist that makes Erlang famous. A Supervisor is an actor whose only job is to start some child actors, watch them, and restart any that crash. Supervisors can supervise other supervisors, forming a supervision tree. The whole app is one tree.

That makes a different error-handling style possible: Let it crash. Don't wrap every line in defensive checks for situations you didn't expect. If a chat-room process gets into a weird state, let it crash; its supervisor starts a fresh copy in a known-good state a millisecond later. Crashing is safe because the room's state was private — nothing else was half-updated when it died.

A supervision tree
Crash a chat room and watch its supervisor restart it. Change the strategy, then crash rooms quickly to hit the restart limit.
app supervisor · one_for_one
chat supervisor · one_for_one
room:lobby
start #1
🔒 3 messages
room:games
start #1
🔒 1 messages
room:music
start #1
🔒 2 messages
logger
never affected
chat restarts in the last 10 s: 0 / 3one_for_one: only the crashed child restarts.
Add a few messages to room:lobby, then crash it. The room comes back — but empty. That's the deal “let it crash” makes.

What the demo shows:

  • The restart strategies. one_for_one: restart just the crashed child — the usual choice. one_for_all: restart all children — for children that can't work without each other. rest_for_one: restart the crashed child and all children started after it — for when later children depend on earlier ones.
  • Restart limits. Crash rooms quickly: after too many restarts in a short window (Elixir's default is 3 in 5 seconds; the demo uses 10 s), the supervisor decides restarting isn't helping, stops its children and crashes itself. Its own supervisor then restarts that whole branch. Failures climb the tree until a level that can fix them — and the logger, in another branch, never notices.
  • Restarting loses in-memory state. The room comes back empty. "Let it crash" only works if anything important was saved somewhere durable (a database, an event log) before the crash.

Across machines: location transparency

A message to an Erlang process looks the same whether that process is in your program or on another machine: send(pid, msg). The BEAM connects nodes over the network and routes the message. This is called Location transparency. Akka Cluster and Microsoft Orleans do the same on their platforms — Orleans even creates an actor automatically the first time someone sends it a message ("virtual actors").

It works because actors were never sharing memory in the first place: a message was already a copy sent to an address. Go channels don't stretch this way — a channel is a value in one program's memory, and an unbuffered send's handshake can't cheaply span a network.

Staff-level§08

Mailboxes in production: overflow, backpressure, ordering

In one sentenceA mailbox lets a sender never wait — which also means nothing ever tells it to slow down. Every actor system needs an answer to "what happens when messages arrive faster than they're handled?"

Overflow: a worked example

An actor handles 5 messages per second. Messages arrive at 8 per second. Every second, 3 more join the queue than leave it:

  • after 1 minute: ~180 messages waiting; a new message waits ~36 s before it's handled;
  • after 1 hour: ~10,800 waiting; a new message waits ~36 minutes.

Nothing errors. The wait for each message (its Latency) just climbs until callers time out, and memory climbs until the process is OOM-killed. Erlang and Akka mailboxes are unbounded by default, so this is the classic actor outage. Try the three policies:

When messages arrive faster than the actor can handle them
The actor handles 5 messages per second. Set the incoming rate above that and watch the mailbox.
mailbox length over timenow: 0
incoming: 8/s · handled: 5/s
a new message waits ≈ 0.0 s before it's handled
memory: grows with every queued message (limit in this demo: 250)
handled so far: 0
Unbounded (Erlang's and Akka's default): nothing ever pushes back. Below 5/s it's fine; above, the queue — and the wait — grow forever.

The three options, and when each fits:

PolicyWhat happens when fullGood for
UnboundedNothing — the queue growsOnly when you know the arrival rate is lower than the handling rate
Bounded, sender waitsThe sender blocks until there's room: BackpressureWork that must not be lost; a Go actor with make(chan any, 64) behaves like this
Bounded, drop / rejectThe new message is thrown away or the send failsMetrics, telemetry, "latest position" updates; or rejecting requests quickly (Load shedding)

Ordering: what you can and can't rely on

Messages from one sender to one receiver arrive in the order they were sent — Erlang and Akka both promise that, and a Go channel is first-in-first-out too. But there is no global order. When messages travel along different paths, a later message can overtake an earlier one:

What order will messages arrive in?
ABm1m2B always sees m1, then m2ABCx (sent 1st)yzB got y, so it sends zz firstx arrives later
Left: messages from one sender to one receiver arrive in the order they were sent (Erlang and Akka both promise this). Right: across different paths there is no promise — C can get z (sent later, via B) before x.

The practical rules:

  • Two messages from you to one actor: safe to rely on their order. (Quiz question 1 depends on this.)
  • Messages from several senders: they interleave in whatever order they arrive. If order matters, have one actor sequence them, or put a sequence number in each message.
  • Messages that travel different paths (the right side of the diagram): A sent x to C before it sent y to B, and z only exists because B got y. It feels like x "must" reach C before z — but nothing promises it. Don't build logic on that kind of cause-and-effect order across actors unless your protocol enforces it (for example, A waits until C confirms x before sending y).
Practitioner§09

Actors or channels? How to choose

In one sentenceAsk "is there a long-lived thing here, or a flow of data?" A thing with identity and state → an actor. A flow through stages → channels. A small shared value → often just a lock.

Shapes that want actors

Something that has an identity, lives for a long time, keeps state, and receives requests from many places:

  • A chat room. One actor per room holds the member list and recent messages. Joins, leaves and posts arrive from many users; the room handles them one at a time, so "post" can never see a half-updated member list.
  • A game entity or a game match. One actor per match owns positions and scores and processes moves in order.
  • A bank account, a shopping cart, a user session, a device connection. One actor per id. Millions of mostly idle actors are cheap on the BEAM — and the per-id split is how you scale: different accounts are handled in parallel, while each single account stays strictly serial.

Shapes that want channels

Data that flows through steps, where no step has to remember much between items:

  • Pipelines: read → parse → transform → write (Channels §06).
  • Worker pools and fan-out: "resize these 10,000 images with 8 workers" (Channels §07). The workers are interchangeable, so naming them would only get in the way.
  • Coordination signals: "stop now" (ctx.Done()), "first result wins" (select), timeouts.

The decision table

Question→ Actor→ Channels→ Lock
Is there a named, long-lived thing with state?✅
Does data flow through stages?✅
Are workers interchangeable?✅
Is it a tiny shared value (a counter, a cache map)?✅ (Channels §10)
Must it survive crashes / run on many machines?✅ with supervisors, cluster (§07)
Should the sender be slowed when the receiver is busy?Needs a bounded mailbox or ask (§08)✅ built in

What your language nudges you toward

  • Go makes channels and goroutines free and built in, so most Go code is CSP plus mutexes. The actor shape (§05) appears whenever one goroutine owns a resource: a connection, a cache that needs batched writes, a rate limiter.
  • JavaScript has no shared-memory threads by default, so locks are rare. Use the mailbox class (§06) to serialize async work on one thing, and Workers when you need real parallelism.
  • Erlang/Elixir and Akka make actors the default, with supervisors and distribution included — the strongest choice when uptime and many machines matter.
Practitioner§10

Pitfalls gallery

#1

The unbounded mailbox

Senders never wait, so nothing slows them down. When the arrival rate is higher than the handling rate, the mailbox grows until the process runs out of memory — and every queued message dies with it. Fix: bound the mailbox (or use a buffered Go channel, which is bounded by definition), use ask so senders wait for replies, and alert on mailbox length. §08 has the simulator.

#2

Two actors asking each other

An actor that waits for a reply inside a handler stops reading its own mailbox until the reply comes. If the other actor does the same thing back, both wait forever: a Deadlock. The cycle can be longer (A → B → C → A), and the simplest version is an actor asking itself (Quiz question 2).

Two actors asking each other
Waiting for a reply inside a handler, vs. treating the reply as a message.
Code
1// Actor A ("orders"), handling one message:
2case placeOrder:
3 p := prices.Price(m.item) // ask B, then WAIT for its reply
4 m.reply <- p * m.qty
5
6// Actor B ("prices"), handling one message:
7case restock:
8 n := orders.Stock(m.item) // ask A, then WAIT for its reply
9 setPrice(m.item, n)
actor A · orders
✉ placeOrder
actor B · prices
✉ restock
what's happening
both actors are healthy
Two actors, A (orders) and B (prices). At the same moment, each receives a message. Each handles one message at a time.
1 / 5

Fixes: inside actors, prefer tell: send the question with your own address as the reply-to, and handle the answer as a normal message later. If you must ask, always with a timeout (§05) — a timeout turns a silent hang into an error you can see. And design so requests flow one way through your actors (orders asks prices, never the reverse).

One Go-specific catch: a "tell" into a full inbox channel still blocks. If A and B both have full inboxes and each is in the middle of telling the other, they jam exactly like the ask version. For actor-to-actor sends, use a select with a timeout (or a default case that drops or logs the message) so a handler can never wait forever on a send:

select {
case prices.inbox <- getPrice{id: m.id, item: m.item, replyTo: a.inbox}:
case <-time.After(100 * time.Millisecond):
    m.reply <- errBusy // prices is overloaded: fail this request instead of jamming
}
#3

Slow or blocking work inside a handler

An actor handles one message at a time, so a 2-second database call inside a handler means every message behind it waits 2 seconds — even a quick "what's your balance?". That's Head-of-line blocking.

✕ Don't
case report:
    rows, err := db.Query(m.sql) // 2 s — the whole mailbox waits
    m.reply <- summarize(rows, err)
✓ Do
case report:
    snapshot := a.copyState()        // quick: just the data the job needs
    go func() {                      // the slow part runs elsewhere…
        m.reply <- buildReport(snapshot)
    }()                              // …and the actor goes straight back to its mailbox

(Here the actor keeps its state in struct fields, a.something, rather than in a loop variable as in §05 — same rule: only the actor's goroutine touches them.) The fixed version hands a copy of the state to a helper goroutine, so the actor's private state is still touched only by the actor. If the result should change the actor's state, the helper sends it back as a message to the actor's inbox. One side effect: replies to offloaded requests can now come back out of mailbox order — a quick request sent later may be answered before the slow report. In JavaScript the same applies: an await inside the Actor handler from §06 holds up the whole queue — that's the point when you need it, and a cost when you don't.

#4

Sending a pointer to your state

Erlang copies every message, and postMessage copies too. Go channels and the JS Actor class pass references: send a map, slice or object and both sides now hold the same data. If the actor sends out a pointer to its private map, the "only the owner touches it" rule is broken and the races from §02 are back.

✕ Don't
case getAll:
    m.reply <- a.items  // the caller now shares the actor's map!
✓ Do
case getAll:
    m.reply <- maps.Clone(a.items) // send a copy (import "maps", Go 1.21+)

maps.Clone makes a shallow copy: a new map with the same keys and values. If the values are themselves pointers or slices, those are still shared — copy them too if the receiver might change them. Same rule as Channels pitfall #8: after you send something, treat it as handed over.

#5

One overloaded actor for everything

An actor is a serial queue. An actor that every request has to go through is called a hot actor. One global counter actor that every request must message is a single-file line that can't use more than one CPU core, however many you have. Fix: split by key — one actor per account, per room, per user — so unrelated work runs in parallel. And if all you need is a counter or a small cache, use an atomic operation or a mutex; an actor is overkill.

#6

Forgetting that a restart loses state

A supervisor restarts an actor with fresh state (§07). If a chat room's messages only lived in the actor's memory, they're gone after a crash. Fix: write anything you can't afford to lose to durable storage before acknowledging it, and rebuild the actor's state from there when it starts.

#7

Messages sent into the void

Sending to an actor that has died doesn't fail — in Erlang the message is silently dropped; in Akka it becomes a "dead letter"; in Go, if nobody reads the inbox channel anymore, the sender blocks forever once the buffer is full. Fix: watch actors you depend on (monitors in Erlang, watch in Akka, ctx.Done() in Go), and put timeouts on every ask.

Practitioner§11

Check yourself

Predict the answer before you click. Where to look if you miss one: Q1 in §03 and §08, Q2 in §05 and §10, Q3 in §04 and §07, Q4 in §06, Q5 in §07, Q6 in §09.

QUESTION 1 / 6
One sender, two messages
// An account actor with balance 40.
// ONE sender does, in this order:
acct.send({ type: "deposit",  amount: 50 });
acct.send({ type: "withdraw", amount: 80 });
// (withdraw is refused if balance < amount)
QUESTION 2 / 6
The actor asks itself
// inside the account's loop (the only goroutine reading a.inbox)
case deposit:
    bal += m.amount
    log.Println("new balance:", a.Balance()) // Balance() from §05
QUESTION 3 / 6
What does an actor send do?
# Elixir. counter is busy with a slow message right now.
send(counter, {:add, 1})
IO.puts("next line")
QUESTION 4 / 6
An async handler behind a mailbox
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const logger = new Actor(async (msg) => {   // the Actor class from §06
  console.log("start", msg);
  await sleep(10);
  console.log("end", msg);
});
logger.send("a");
logger.send("b");
QUESTION 5 / 6
Supervision strategies
chat supervisor, strategy: rest_for_one
children, in start order:  lobby, games, music
→ games crashes
QUESTION 6 / 6
Pick the better fit
Job: read 1 million image files, resize each,
then upload. No state needs to be kept between images.
Answered 0 / 6
Foundations§12

Cheat sheet

📬
Actor = state + mailbox + behaviorPrivate data, a queue of messages, one message handled at a time. No locks inside.
🏷️
Send TO vs send ONActors: you address a worker (PID, ActorRef). CSP: you address a channel; any receiver may take it.
⏱️
Who waitsActor send: never waits with an unbounded mailbox; a bounded one waits (or drops) when full. Unbuffered channel send: waits for a receiver. Buffered channel: waits only when full.
✉️
Ask = request + reply-toGo: put a make(chan T, 1) in the request. JS: a promise's resolve. Always with a timeout.
🌳
Supervisors restart crashesone_for_one / one_for_all / rest_for_one, with a restart limit. Restarted actors start with fresh state.
📈
Bound every mailboxUnbounded mailboxes turn overload into out-of-memory. Bound them, use ask, or drop on purpose — and monitor queue length.
🔁
Order: per pair onlyOne sender → one receiver keeps order. Nothing is promised across different senders or paths.
🚫
Never wait inside a handlerNo asks between actors that can form a cycle; no slow I/O in the handler. Reply-as-message, or offload.
I want to…Use
One owner for a stateful thing (account, room, connection)An actor: Go goroutine + inbox channel (§05), JS Actor class (§06), GenServer, Akka actor
Stream data through stagesChannels and a pipeline (Channels §06)
Spread work over interchangeable workersA worker pool on one channel (Channels §07)
Get an answer from an actorAsk with a reply channel / promise + timeout (§05)
Run JS in parallel without shared memoryA Web Worker / worker_threads + postMessage (§06)
Recover from unexpected crashes automaticallySupervision tree (Erlang/Elixir, Akka/Pekko) (§07)
Protect a tiny shared counter or mapA mutex or atomic, not an actor (Channels §10)
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.