Concurrent software becomes difficult when many execution contexts can read and mutate the same state. Locks can make that access legal, but they do not automatically make ownership, failure, overload, or distribution understandable. The actor model starts from a different constraint: state belongs to an actor, and other participants can affect it only by sending messages.
An actor is a small state machine with an address and a mailbox. It processes one message at a time, may update private state, send more messages, or create actors. That sounds modest, yet the constraint removes an entire class of data races inside an actor. The hard problems do not disappear; they move to protocol design, delivery semantics, queue growth, recovery, and coordination across actors. That is usually a better place to reason about them because those decisions become visible.
Isolation, messages, and mailbox ordering
Suppose an inventory actor owns the available quantity for one SKU. A checkout worker cannot decrement an integer directly. It sends Reserve, receives Reserved or Rejected, and reacts. The inventory state has one owner, so its transition from 10 to 8 cannot interleave halfway through another transition.
Each actor conceptually repeats three steps:
- Dequeue one message.
- Run the behavior against private state.
- Commit the new state and emit effects before handling the next message.
The runtime may schedule thousands of actors across a small thread pool. Sequential processing is per mailbox, not a promise that each actor owns a thread. That distinction is why actors can be lightweight.
Ordering guarantees are narrower than people often assume. Many actor runtimes preserve FIFO order for messages sent directly from actor A to actor B. If A sends Debit(10) and then GetBalance, B observes them in that order. Messages from A and C can arrive at B in either order. Retries, routers, persistence layers, and network reconnection can weaken ordering further. A protocol should depend only on the runtime’s documented guarantee, not on a global timeline that does not exist.
Messages should be immutable values. Mutating an object after sending it quietly reintroduces shared memory, especially in in-process runtimes that pass references rather than serializing payloads. Model commands and replies as discriminated unions, include stable identifiers, and make duplicate handling explicit when delivery can be retried.
Note
One-message-at-a-time prevents concurrent mutation inside one actor. It does not make a workflow across several actors atomic. A transfer still needs a protocol for partial completion, timeout, compensation, or durable coordination.
A small typed actor and mailbox in TypeScript
The following runtime is intentionally small, but it preserves the central rules: every actor has a private mailbox, only one drain loop runs at a time, state changes are serialized, and a supervisor chooses what happens after failure. Production frameworks add dispatchers, remote transport, persistence, telemetry, dead letters, and lifecycle hooks.
type ActorRef<Message> = {
tell(message: Message): void;
};
type Directive = 'restart' | 'resume' | 'stop';
type Behavior<State, Message> = (
state: State,
message: Message,
context: { self: ActorRef<Message> },
) => State | Promise<State>;
function spawn<State, Message>(options: {
initialState: () => State;
behavior: Behavior<State, Message>;
supervise?: (error: unknown, message: Message) => Directive;
}): ActorRef<Message> {
const mailbox: Message[] = [];
let state = options.initialState();
let draining = false;
let stopped = false;
const ref: ActorRef<Message> = {
tell(message) {
if (stopped) return;
mailbox.push(message);
void drain();
},
};
async function drain(): Promise<void> {
if (draining || stopped) return;
draining = true;
try {
while (!stopped) {
const message = mailbox.shift();
if (message === undefined) break;
try {
state = await options.behavior(state, message, { self: ref });
} catch (error) {
const directive = options.supervise?.(error, message) ?? 'stop';
if (directive === 'restart') state = options.initialState();
if (directive === 'stop') {
stopped = true;
mailbox.length = 0;
}
}
}
} finally {
draining = false;
if (!stopped && mailbox.length > 0) void drain();
}
}
return ref;
}
type CounterMessage =
| { type: 'increment'; amount: number }
| { type: 'read'; replyTo: ActorRef<{ value: number }> }
| { type: 'crash' };
const counter = spawn<number, CounterMessage>({
initialState: () => 0,
async behavior(state, message) {
switch (message.type) {
case 'increment':
if (message.amount < 0) throw new Error('negative increment');
return state + message.amount;
case 'read':
message.replyTo.tell({ value: state });
return state;
case 'crash':
throw new Error('simulated failure');
}
},
supervise(error) {
console.error('counter failed', error);
return 'restart';
},
});
counter.tell({ type: 'increment', amount: 2 });
counter.tell({ type: 'increment', amount: 3 });
The draining flag matters. Without it, two rapid tell calls could start two asynchronous loops, both read the same state, and violate actor isolation. The finally block closes another race: a message can arrive after the loop sees an empty mailbox but before it clears draining; checking the queue afterward ensures that message is not stranded.
This example also exposes an important restart limitation. Reinitializing state to zero is mechanically correct but may be semantically disastrous. A real counter might restore a snapshot and replay durable events, while an ephemeral cache might safely start empty. Supervision decides lifecycle; recovery decides data.
Tell, ask, and location transparency
tell is fire-and-forget from the sender’s perspective. It decouples the sender’s progress from the receiver and fits notifications, commands with independent acknowledgements, and streaming work. Failure may be reported through another message, telemetry, or a dead-letter channel rather than a thrown exception.
ask creates a temporary reply address and returns a future or promise. It is convenient at HTTP boundaries and for genuine queries, but every ask introduces a dependency on a timely answer. It needs a timeout, correlation identifier, cancellation policy, and an interpretation for a late reply. Chains such as A asks B, B asks C, and C asks D reproduce synchronous call stacks with network latency and distributed failure.
| Interaction | Sender waits? | Best fit | Main risk |
|---|---|---|---|
| Tell | No | Commands, notifications, pipelines | Lost or unobserved failure |
| Ask | Yes, up to a timeout | Queries and boundary adapters | Cascading latency and timeout ambiguity |
| Shared object with locks | Usually | Tight in-process algorithms | Contention, deadlock, unclear ownership |
| Stream/channel | Controlled by demand | Ordered data flow | Protocol and buffer complexity |
Actor references provide a degree of location transparency: callers can use the same messaging API whether the actor is on this process or another node. The semantics are not transparent. A local send may be a cheap enqueue of an object reference; a remote send requires serialization, transport, authentication, version compatibility, and has partitions. Payload size matters remotely. So do clocks, deployment topology, and whether a reply route survives node failure.
Treat location transparency as an API convenience, not proof that location is irrelevant. Remote-capable protocols should use serializable messages, explicit deadlines, idempotency keys where retries occur, and version-tolerant schemas. Avoid putting closures, open file handles, or mutable domain objects into messages merely because the local runtime accepts them.
Warning
A successful tell usually means “accepted by the local runtime,” not “processed exactly once by the destination.” Document whether delivery is at-most-once, at-least-once, or persistence-backed, then design idempotency around that contract.
Supervision, restart, and state recovery
Actors make failure a first-class message-routing and lifecycle concern. A parent or designated supervisor observes a child failure and chooses a directive. Common choices are resume the current state, restart behavior with reconstructed state, stop the actor, or escalate so a higher-level supervisor decides. The right choice depends on whether the fault is transient, whether state may be corrupt, and whether siblings share the same failed dependency.
A useful supervision tree follows operational dependencies. If a connection-pool actor fails, workers that depend on that pool may need to restart together. If one tenant’s parser crashes on malformed input, restarting every tenant is excessive. Frameworks often call these one-for-one and all-for-one strategies.
Restart is not rollback. The failed message may already have written to a database or sent an email before throwing. Replaying it can duplicate the effect. Side effects need transaction boundaries, deduplication keys, an outbox, or a workflow that can compensate. Poison messages should eventually go to quarantine or dead letters rather than trigger an infinite restart loop.
State recovery generally uses one of three approaches:
- Rebuild disposable state from configuration or an authoritative service.
- Load a recent snapshot and replay events after its sequence number.
- Rehydrate from a database record, using optimistic concurrency to reject stale writes.
Persistent actors need stable identity and schema evolution. An actor moving to another node must recover the same logical entity, not create a second writer. During a network partition, cluster membership and sharding rules must prevent or reconcile split ownership. This is where actors meet consensus, leases, fencing tokens, and the ordinary constraints of distributed systems.
Backpressure, clustering, and testing protocols
An unbounded mailbox converts overload into memory growth and latency. The service may look healthy while messages wait for minutes. A bounded mailbox makes capacity explicit, but the sender then needs a policy: reject, drop an old or low-priority message, retry with jitter, spill durably, or slow upstream demand. There is no universally safe default.
Credit-based flow control works well for actor pipelines. A consumer grants a producer permission to send items, replenishing credit as it completes work. Pull-based workers can instead request the next job only when idle. Both approaches keep queue length tied to real capacity. Monitoring should include mailbox depth, oldest-message age, processing time, rejection rate, restart count, and dead letters; CPU alone cannot reveal queue distress.
Clustering adds placement and identity. A router may distribute stateless jobs across a pool. Entity sharding maps a stable key, such as account-42, to exactly one active actor and relocates it when membership changes. Cluster singletons are useful for narrow coordination tasks but can become bottlenecks. Node discovery also does not solve delivery, state replication, or split brain by itself; those remain framework- and storage-specific guarantees.
Actor tests should emphasize observable protocols rather than scheduler timing:
- Send a sequence of commands and assert the replies or emitted events.
- Use probe actors as
replyTorecipients and inspect their messages. - Replace clocks and timers with controllable test schedulers.
- Inject failures and verify restart limits, recovered state, and dead letters.
- Property-test invariants across randomized message sequences, such as “inventory never becomes negative.”
- Run multi-node tests for serialization, partitions, rebalance, duplicate delivery, and stale ownership.
Avoid tests that sleep for 100 milliseconds and then inspect internal fields. They are slow, race-prone, and couple the test to scheduling rather than behavior. A deterministic actor runtime can process one queued message per test step, making ordering and supervision scenarios precise.
When actors help, when they hurt, and takeaways
Actors are strongest when the domain already contains many independent, stateful entities: devices, game rooms, accounts, shopping carts, workflow instances, websocket sessions, or simulations. They also fit workloads where failures should be isolated and where messages form the natural integration boundary. The model gives each entity serialized state transitions and a place to attach lifecycle policy.
They hurt when a task is a simple request-response CRUD path, a bulk numeric computation requiring shared arrays and data-parallel kernels, or a pipeline already modeled cleanly by streams. Millions of tiny actors can add mailbox, routing, serialization, and observability overhead without adding useful ownership. Cross-actor transactions can turn one local invariant into a distributed protocol. Debugging also shifts from stack traces to causal message histories, which demands correlation IDs and good tracing.
Do not adopt actors merely to avoid learning locks, and do not reject them because they cannot hide a network partition. Choose them when isolated ownership and explicit asynchronous protocols match the problem.
The practical takeaways are:
- An actor owns private state and processes one mailbox message at a time; this removes internal data races, not distributed coordination.
- Ordering is usually per sender-receiver pair, never global. Delivery and retry guarantees must be explicit.
- Prefer
tellfor decoupled flow and useaskat deliberate boundaries with deadlines and late-reply handling. - Supervision chooses lifecycle after failure; snapshots, logs, databases, and idempotent effects provide recovery.
- Bound mailboxes and propagate demand. Queue age and depth are correctness signals, not merely performance metrics.
- Location-transparent references do not make serialization, latency, partitions, or schema compatibility disappear.
- Test protocols, invariants, failure directives, and rebalance behavior rather than private state or wall-clock sleeps.
The actor model is valuable because it draws borders: this state has one owner, this work arrived as a message, this queue has finite capacity, and this supervisor owns the failure decision. Systems remain concurrent and distributed, but their hazards gain names, addresses, and testable protocols.