← Concepts & practices
Concept Concurrency, scheduling, and delivery

Actor model

Give state one owner, and send it messages.

You already talk to a Web Worker by posting messages, never by reaching into its variables. Let’s follow a charity auction whose items are updated by every request at once, until Ben bids $150 and the item ends the night at Ana’s $130.

TypeScriptGo One auction, two implementations.

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
auction.ts
// 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.

TypeScriptReading
auction.ts
// 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 };
}
GoAlongside
auction.go
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.

Actor model

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.

01/ 05
Send three bids to a shared item

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.

What does Ben’s phone show?

The item now tells the old leader they’ve been outbid. While handling a bid, it calls await actor.send({ type: 'notify', bidder: item.leader }) on itself, then records the new bid. Ana bids $130 and is accepted. Then Ben bids $150.

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.

Worker

Owns the history

Handles one message at a time and replies with copies.

Socket

Sends bids in

Forwarded to the worker as they arrive.

Page

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.

bid-history.ts
// 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.

ReactAlready in your code
TopBids.tsx
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.

Familiar code, the owner of its state, and how others reach it
Where you’ve seen itThe ownerHow others reach it
A Web WorkerThe worker’s own scopepostMessage, with copied data
A goroutine ranging over a channelThat goroutineSends on the channel
A reducer with dispatchThe storeActions, handled one at a time
A job queue with one worker per accountThat workerJobs on the account’s queue
This auction itemThe item actorBid 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.

Read the article ↗

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.

How a change affects a shared item and an item actor
The changeShared itemItem actor
Bids arrive one at a timeCorrect and simpler.A queue with nothing in it.
Add a second await to placeBidAnother gap for bids to interleave.Still one message at a time.
A thousand bids in a minuteMore interleaving, more lost bids.A queue to bound and watch.
Notify the old leaderCall it inline.Send it without waiting, or to another actor.
Run on several serversNeeds 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

Take the auction into your editor. Fix createNotifyingItem so the notice doesn’t wait, give each item its own actor, and add a test that fails on the shared version.

Back to Concepts & practices →