01 / The idea
One item object that every bid updates is a fair start.
You’re building bidding for a charity silent auction. placeBid reads the item’s high
bid, turns away bids that can’t win, waits for the payment provider to check the bidder’s card,
and then records the new high bid. Checking first saves a fee on losing bids, and while bids arrive
one at a time, it’s correct.
Read the first versionTypeScript · the version this lesson starts from
// The first version: one item object, updated by whichever request is running.
export function createSharedItem(opening: number, check: Check = paymentCheck) {
const item: ItemState = { highBid: opening, leader: 'opening price', accepted: [] };
async function placeBid(bid: Bid): Promise<Reply> {
const highBid = item.highBid;
// Don't pay for a payment check on a bid that can't win.
if (bid.amount <= highBid) return { accepted: false, highBid, leader: item.leader };
await check(bid); // Other bids run while this one waits.
item.highBid = bid.amount;
item.leader = bid.bidder;
item.accepted.push(`${bid.bidder} $${bid.amount}`);
return { accepted: true, highBid: item.highBid, leader: item.leader };
}
return { placeBid, state: (): ItemState => ({ ...item, accepted: [...item.accepted] }) };
} Go’s SharedItem takes a mutex for the read and again for the write. Both languages
meet again at the actor in section 02.
Then the last minute of the auction arrives. Ana bids $130 and her bank is slow. Ben bids $150 and Cy $140, and both checks come back quickly. All three bids read $100 before any check finished, so each one writes when its check returns. Ben’s $150 lands first, and Cy’s and Ana’s lower bids write over it.
The actor model gives each piece of state one owner, called an actor, and lets everything else reach it only by sending messages. The actor handles one message at a time, so no two changes to its state can interleave, even when handling a message means waiting. Carl Hewitt introduced the model in 1973. Wikipedia’s summary puts it this way: actors “may modify their own private state, but can only affect each other indirectly through messaging”.
Section 05 builds a leaderboard whose bid history is owned by a Web Worker, in React and Svelte.
02 / See the shape
Keep the state inside; let messages in one at a time.
The basic form is a small actor: private state and a mailbox. In the wild is the auction item as an actor, with a mailbox that turns bids away when it’s full. At the call site the closing bids go through both versions.
Both languages end with the same leaders, replies, and counts.
An actor: state only it touches, and a mailbox that handles one message at a time. TypeScript chains promises; Go runs one goroutine over a channel.
// An actor owns its state. Other code sends it messages, and it handles them one at a time: each
// message finishes, awaits included, before the next one starts.
export function createActor<State, Message, Result>(
initial: State,
handle: (state: State, message: Message) => Promise<[State, Result]>
) {
let state = initial;
let mailbox: Promise<unknown> = Promise.resolve();
let waiting = 0;
const stats = { handled: 0, mostWaiting: 0 };
function send(message: Message): Promise<Result> {
waiting++;
stats.mostWaiting = Math.max(stats.mostWaiting, waiting);
const result = mailbox.then(async () => {
try {
const [next, reply] = await handle(state, message);
state = next;
return reply;
} finally {
waiting--;
stats.handled++;
}
});
mailbox = result.catch(() => undefined); // A failed message doesn't stop the ones behind it.
return result;
}
return { send, stats, waiting: () => waiting, snapshot: () => state };
} type envelope[M, R any] struct {
message M
reply chan R
}
// Actor owns its state inside one goroutine. Other goroutines reach it only through its inbox,
// and it handles one message at a time.
type Actor[S, M, R any] struct {
inbox chan envelope[M, R]
handle func(S, M) (S, R)
state S
done chan struct{}
MostWaiting int // updated by TrySend; call TrySend from one goroutine
}
func NewActor[S, M, R any](initial S, capacity int, handle func(S, M) (S, R)) *Actor[S, M, R] {
return &Actor[S, M, R]{inbox: make(chan envelope[M, R], capacity), handle: handle, state: initial, done: make(chan struct{})}
}
// Open starts the goroutine that owns the state.
func (a *Actor[S, M, R]) Open() {
go func() {
defer close(a.done)
for env := range a.inbox {
next, reply := a.handle(a.state, env.message)
a.state = next
env.reply <- reply
}
}()
}
// Send waits for room in the inbox, then for the reply.
func (a *Actor[S, M, R]) Send(message M) R {
reply := make(chan R, 1)
a.inbox <- envelope[M, R]{message, reply}
return <-reply
}
// TrySend queues a message without waiting, or reports that the inbox is full.
func (a *Actor[S, M, R]) TrySend(message M) (<-chan R, bool) {
reply := make(chan R, 1)
select {
case a.inbox <- envelope[M, R]{message, reply}:
a.MostWaiting = max(a.MostWaiting, len(a.inbox))
return reply, true
default:
return nil, false
}
}
// Close stops accepting messages, waits for the queue to drain, and returns the final state.
func (a *Actor[S, M, R]) Close() S {
close(a.inbox)
<-a.done
return a.state
} Reading the TypeScriptA mailbox made of promises
mailbox is the promise for the last message. Each send chains the next message onto it with then, so a handler starts only when
the one before it has finished, including everything it awaited.
mailbox = result.catch(...) keeps a failed message from breaking the chain.
The caller still sees the failure through the promise send returned.
Reading the GoA goroutine and a channel
One goroutine ranges over the inbox and is the only code that touches state. Effective Go: “Only one goroutine has access to the value at any
given time. Data races cannot occur, by design.” Each message carries its own reply
channel.
The inbox is a buffered channel. TrySend uses select with default, so a full inbox answers at once instead of blocking the sender.
In NewItem the payment check returns an error. A declined card
leaves the item unchanged and goes back in that bid’s reply, and the goroutine moves on
to the next message.
03 / Follow the bids
Watch when each check starts, and whose write lands last.
Five steps. Each sends bids through the lesson’s code with a payment check that records when it starts; replies are recorded as they arrive. Before each step, guess who leads.
In Try it, set the bids and check speeds yourself.
Who gets to change the item?
One shared item. Ana: checking $130. Ben: checking $150. Cy: checking $140. Ben: accepted, now leads at $150. Cy: accepted, now leads at $140. Ana: accepted, now leads at $130. leader Ana at $130, bids accepted 3. All three read $100 before any check finished. Ben’s $150 landed first, then Cy’s and Ana’s lower bids wrote over it.
Checks interleave.
Every bid read $100 before any check finished, so the last write wins, not the highest bid.
Reduced motion: choose a scene to see its completed state.
Read this scene
Every bid read $100 before any check finished, so the last write wins, not the highest bid.
One shared item. Ana: checking $130. Ben: checking $150. Cy: checking $140. Ben: accepted, now leads at $150. Cy: accepted, now leads at $140. Ana: accepted, now leads at $130. leader Ana at $130, bids accepted 3. All three read $100 before any check finished. Ben’s $150 landed first, then Cy’s and Ana’s lower bids wrote over it.
Watch restarts when you return. Step through keeps your selected step. Try it starts with Ana, Ben, and Cy’s bids each time you open it.
What one owner buys you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- No lost updates
- Ben’s $150 stands; Cy’s $140 is compared with it, not with $100.
- No locks to forget
- Only the actor touches the item, so there’s nothing to lock.
- Failures stay in their message
- A declined card fails Ben’s bid; Cy’s is handled next.
- A place to say no
- A full mailbox tells 180 bidders the item is busy.
- Owners work independently
- The wine’s bids finish while the quilt waits on a slow check.
The review words are actor, mailbox (or inbox), message passing, share memory by communicating, lost update for what happened to Ben, race condition for its cause, serialized for one message at a time, and backpressure for the busy replies. Section 08 covers what they cost.
04 / Try a decision
An item that messages itself.
The item should tell the old leader they’ve been outbid. Someone does it with a message to
the item. The code is in notify-self.ts, and the lesson’s tests pin what
happens.
05 / Give it a real job
A leaderboard whose history lives in a worker.
In the real app, a big screen at the gala shows the leading bid on every item, fed by a live socket with hundreds of bids a minute. Keeping and sorting that history shouldn’t block the page, and nothing on the page should be able to change it by accident.
Owns the history
Handles one message at a time and replies with copies.
Sends bids in
Forwarded to the worker as they arrive.
Asks and shows
Posts questions and keeps only the latest answer.
The example leaves out reconnecting the socket, the server’s own actor for each item, and sharing one worker between tabs.
Build UIs?A Web Worker is the browser’s own version: state on the other side of a message port, reached only by posting.
Where it already is in your components
MDN: “Data is sent between workers and the main thread via a system of messages — both
sides send their messages using the postMessage() method, and respond to
messages via the onmessage event handler”. And “Data passed between the main
page and workers is copied, not shared”, which is what keeps the worker’s state
its own.
The textbook component starts the worker with the syntax Vite recommends, new Worker(new URL('./bid-history.worker.ts', import.meta.url), { type: 'module' }), posts a bid and a question, and shows the reply.
When you have to own it
Now it’s the gala leaderboard. Socket bids go straight to the worker, which keeps the leading bid per item. The page asks for the top items every second and when the count changes, and tags each question with a request id. The worker answers in the order it was asked, so answers never overtake each other; the id lets the page drop an answer to a question it has since replaced, like a top-5 answer that lands after the switch to top 10.
receive(state, message) is a plain function, so the worker’s behavior is tested
without a worker.
// The bid history a worker owns. The page never touches this state: it posts messages, and gets
// copies of the answers back.
export type HistoryMessage =
| { type: 'bid'; item: string; bidder: string; amount: number }
| { type: 'top'; requestId: number; count: number };
export type TopBid = { item: string; bidder: string; amount: number };
export type HistoryReply = { requestId: number; top: TopBid[] };
export type HistoryState = { leaders: Record<string, TopBid> };
export const emptyHistory: HistoryState = { leaders: {} };
// Handles one message and returns the next state, and a reply for questions.
export function receive(
state: HistoryState,
message: HistoryMessage
): [HistoryState, HistoryReply | null] {
if (message.type === 'bid') {
const current = state.leaders[message.item];
if (current && current.amount >= message.amount) return [state, null];
const { item, bidder, amount } = message;
return [{ leaders: { ...state.leaders, [item]: { item, bidder, amount } } }, null];
}
const top = Object.values(state.leaders)
.sort((a, b) => b.amount - a.amount)
.slice(0, message.count);
return [state, { requestId: message.requestId, top }];
}
A component that starts a worker, posts a bid and a question, and shows the reply without ever holding the history.
import { useEffect, useRef, useState } from 'react';
import type { HistoryMessage, HistoryReply, TopBid } from './bid-history';
// The worker owns the bid history. This component only posts messages and shows replies.
export function TopBids() {
const worker = useRef<Worker | null>(null);
const [top, setTop] = useState<TopBid[]>([]);
useEffect(() => {
const history = new Worker(new URL('./bid-history.worker.ts', import.meta.url), {
type: 'module'
});
history.onmessage = (event: MessageEvent<HistoryReply>) => setTop(event.data.top);
worker.current = history;
return () => history.terminate();
}, []);
function send(message: HistoryMessage) {
worker.current?.postMessage(message);
}
return (
<section>
<button
type="button"
onClick={() => {
send({ type: 'bid', item: 'quilt', bidder: 'You', amount: 160 });
send({ type: 'top', requestId: Date.now(), count: 5 });
}}
>
Bid $160 on the quilt
</button>
<ol>
{top.map((bid) => (
<li key={bid.item}>
{bid.item}: ${bid.amount} ({bid.bidder})
</li>
))}
</ol>
</section>
);
}
06 / Recognize it elsewhere
Anywhere state has one owner and a queue in front of it.
You’ve used all of these. For each one, find the owner and the messages.
| Where you’ve seen it | The owner | How others reach it |
|---|---|---|
| A Web Worker | The worker’s own scope | postMessage, with copied data |
| A goroutine ranging over a channel | That goroutine | Sends on the channel |
A reducer with dispatch | The store | Actions, handled one at a time |
| A job queue with one worker per account | That worker | Jobs on the account’s queue |
| This auction item | The item actor | Bid messages |
Go’s blog put the rule in one line in 2010: “Do not communicate by sharing memory; instead, share memory by communicating.” When two pieces of code both write to the same state, ask which one should own it.
07 / Already in your toolbox
Your languages already take this side.
Three places to look. For each one, find what it says about owning data.
Go blog · Share Memory By Communicating
A short post on handing data between goroutines so only one has it at a time.
Read the post ↗Go · Data Race Detector
What a data race is, how -race finds them, and why it only finds the ones your
run reaches.
MDN · Using Web Workers
Messages, copied data, and the browser’s built-in way to give state its own thread.
Read the guide ↗A useful counterexample: state in a databaseWhen a transaction is the owner
If the high bid lives in a database and several servers take bids, an in-process actor doesn’t help: each server would have its own. A conditional update or a transaction is the owner there. Actors fit state that one process holds.
08 / The parts to watch
One at a time is a guarantee and a bottleneck.
These are the places it still goes wrong.
Waiting on yourself never ends
An actor that awaits a reply from a message behind its own mailbox, directly or through another actor that waits on it, stops for good. In Go, if nothing else is running, the runtime reports “all goroutines are asleep - deadlock!”.
A slow message holds up the line
Cy’s $140 waited for Ana’s slow bank before being rejected. Keep handlers short, and give independent state its own actor, as the quilt and the wine have.
A mailbox with no limit is a memory problem
Messages that arrive faster than they’re handled pile up. Bound the mailbox and decide what a full one says. Backpressure & queues covers the choices.
Leaking the state undoes it
If the actor hands out its state object and a caller changes it, the one-owner rule is gone. Reply with copies or values that can’t be changed, as the worker does by design.
Order is per mailbox, not global
Each item handles its own bids in order, but nothing orders bids across items. Anything that must hold across two items needs one owner for both, or a protocol between them.
Tests that pass may still race
Go’s docs: “The race detector only finds races that happen at runtime”. The shared item’s bug needs a particular timing; this lesson forces that timing so the tests always see it.
09 / Make the call
What would you have to change tomorrow?
Give both versions a plausible change and follow the work it creates.
| The change | Shared item | Item actor |
|---|---|---|
| Bids arrive one at a time | Correct and simpler. | A queue with nothing in it. |
Add a second await to placeBid | Another gap for bids to interleave. | Still one message at a time. |
| A thousand bids in a minute | More interleaving, more lost bids. | A queue to bound and watch. |
| Notify the old leader | Call it inline. | Send it without waiting, or to another actor. |
| Run on several servers | Needs the database to decide. | Needs the database to decide, too. |
Give state one owner and send it messages when many callers change it and any of them waits in the middle. An auction item in its last minute is the moment.
Keep plain shared state when changes can’t interleave, or when a database transaction already owns the decision.
The question I’d leave beside the code is: who owns this state, and what can happen between reading it and writing it?
10 / Take the idea with you
Explain Ben’s missing $150 without saying “actor.”
“Every bid read the price, waited for the bank, then wrote. They all read $100, so whichever bank answered last won, even with a lower bid. Now one piece of code owns each item and takes bids one at a time, so every bid is compared with the real current price.” In a review, the words are actor, mailbox, and lost update.
Before moving on, jot down why the lower bid won, why the self-notifying item stopped, and one piece of state in your code that two async functions both read and then write.
Connections to follow nextRelated lessons
- Race conditions in UI covers responses that arrive out of order.
- Backpressure & queues covers what a full mailbox should do.
- Idempotency & at-least-once delivery covers messages that arrive twice.
- Mediator routes messages between parts; an actor owns the state they change.