The actor model, minus the cluster
What the actor model guarantees, when a table beats an actor, and how far one mailbox and a single PostgreSQL row can carry you. No Orleans or Akka required.
Years ago I built digital twins for factory machines: one live software object per physical machine, ingesting sensor readings, tracking state transitions, notifying everything downstream. The naive design is a shared object and a lock, and it dies the way shared objects always die. Contention on the hot machines. Deadlocks between “update state” and “read state for the dashboard.” Race conditions that only reproduce on the shop floor.
What made that system boring to operate (boring in the best way) was the actor model: each twin became an actor that owns its state outright and communicates only through messages. No locks in application code, no races, and a mental model an intern can hold: one machine, one mailbox, one message at a time. It’s the same recipe that lets WhatsApp (Erlang) and Discord (Elixir) push absurd volumes of real-time messages with small teams. The model has been carrying production systems since the Erlang VM made it famous.
But most explanations of actors stop at the sales pitch and skip the two questions that decide real projects: when is an actor actually the right tool, and what does it cost to distribute one? Having since built an actor runtime into Elarion, my open-source .NET application framework, I want to answer both properly.
Before you dive in: this post assumes you’ve met threads and shared-state races before, and it gets deeper as it goes. The first two sections are the on-ramp; from “Distribution is the price cliff” onward it’s architect territory, and it’s fine to stop before that.
What the model actually guarantees
Strip the mystique and an actor is three things: private state, a mailbox, and the rule that messages are processed one at a time. That last clause is the entire magic trick. Your code appears lock-free, but the mutual exclusion didn’t disappear; it moved into the mailbox, where a queue serializes access far more gracefully than a lock statement ever will. No deadlocks by construction, because nobody holds anything while waiting. Natural backpressure, meaning that a full queue makes senders wait instead of losing messages, because the queue has a depth you can observe and bound. And state transitions you can read as straight-line code.
In .NET, the honest implementation of a mailbox is not exotic. Elarion’s actor cell is literally a Channel<T> with a single reader loop:
/// One activation: a mailbox (channel), the actor instance, its DI scope, and/// the processing loop. Non-reentrant cells process one work item start-to-finish.internal sealed class ActorCell<TActor> : IActorActivationRetainer where TActor : class { private readonly Channel<ActorWorkItem<TActor>> _mailbox;Which is also the demystifying takeaway for Go and Rust folks: a goroutine draining a channel is a mini-actor. If your concurrency problem lives inside one process, a channel plus an owning loop may be all the actor model you need. No framework, no ceremony.
Most applications need zero actors
This is the section the evangelists skip, so let me put it in bold: if the solution sketches cleanly as a table with a version column, you don’t need an actor. Optimistic concurrency, unique constraints, idempotency keys, job claims, and the transactional outbox solve most “two things raced” problems while keeping state where it’s durable, queryable, and boring. Elarion’s own documentation opens its actor chapter by talking you out of actors, with a table I’d frame on the wall:
| Symptom | Reach for |
|---|---|
| Two users race to update a record | Optimistic concurrency |
| A webhook can arrive twice | Idempotency key or consumer inbox |
| A follow-up must survive the request | Outbox integration event |
| One import per tenant at a time | Scheduled job claims |
| One ordered TCP/API session per key | Actor owning the resource |
| Live, disposable per-key readings | Actor with no snapshot |
The dividing line: an actor is justified only when the consistency unit is a live, in-memory thing. A connection. A stream position. A conflation window. A machine’s current condition. My digital twins qualified precisely because a row can’t debounce a 100 Hz sensor or hold an open connection. A shopping cart does not.
A real actor, and what the mailbox buys it
Here’s a production-shaped example: a stock-quote actor from Elarion’s samples that ingests a market feed and pushes conflated updates to browsers. One instance exists per symbol; the framework generates a typed facade (IStockQuote) so callers write actors.Get<IStockQuote>("ELN").GetQuote(), and every call is enqueued into the mailbox:
[Actor(Placement = ActorPlacementMode.SingleHome)]public sealed class StockQuoteActor( IActorContext<string> context, IClientEventPublisher clientEvents, TimeProvider timeProvider) : IActorLifecycle {
private decimal _price; private long _seq = -1; private long _lastPublishTimestamp;
public async Task Apply(QuoteTick tick, CancellationToken ct) { if (tick.Seq <= _seq) return; // stale delivery: drop _seq = tick.Seq; _price = tick.Price;
// Conflate: keep the true value every turn, push at most every 250 ms. var now = timeProvider.GetTimestamp(); if (_lastPublishTimestamp != 0 && timeProvider.GetElapsedTime(_lastPublishTimestamp, now) < PublishInterval) return;
_lastPublishTimestamp = now; await clientEvents.PublishAsync(ToQuoteChanged(tick.At), ClientEventScope.Resource(context.Key), ct); }}Notice what’s absent: locks, Interlocked, ConcurrentDictionary. Ordinary mutable fields, mutated freely, because “the mailbox is the whole concurrency story: feed ticks and any other input are applied one turn at a time.” The actor even does double duty as a rate limiter. No dashboard needs every tick, so it conflates to one push per 250 ms per symbol, driven by the tick stream itself, no timer required. And it deliberately persists nothing: after a restart, old prices are worth nothing; the feed re-primes every symbol within a tick. Knowing when state is disposable is as much an actor skill as knowing when to snapshot it.
One footgun deserves its own section: reentrancy. The plain-words version first: an actor that, while handling one message, ends up waiting on itself is stuck, and async code makes that easy to do by accident. By default one message runs start-to-finish, and an await inside a method holds the mailbox. That makes every method body a critical section (nothing else touches the actor until the method returns), and it makes A→B→A call cycles deadlock (Elarion gives facade calls a 30-second timeout so the cycle fails visibly instead of hanging). Orleans-style opt-in interleaving exists: with [Reentrant], turns interleave at await points but never run in parallel, at the price every Orleans developer knows, namely that state observed across an await may have changed. And in .NET there’s a uniquely nasty escape hatch: ConfigureAwait(false) inside an actor method hops off the exclusive scheduler and silently forfeits the single-threaded guarantee. Elarion ships an analyzer (ELACT006) that flags it, which tells you how easy the mistake is to make.
Here’s the cycle in motion. Run it as-is and watch the three-way wait resolve into a timeout; then check [Reentrant] and run it again:
A.Ping() → B.Prepare() → A.GetStatus(): a call cycle
actor A
idle
actor B
idle
- press run; try it non-reentrant first…
Distribution is the price cliff
Here’s where the standard pitch (“and when traffic grows, the framework distributes your actors across machines!”) deserves scrutiny. Distributed actors are why Orleans and Akka, the established clustered actor platforms in .NET, are big. Placement needs membership and failure detection, split-brain included. Transparent forwarding changes call semantics: retries, at-most/at-least-once ambiguity, cross-node backpressure. Every actor method’s parameters become versioned wire contracts, and deploys need activation handoff. As Elarion’s design notes put it, most of what makes Orleans hard “exists because it clusters.”
So before paying that bill, ask what you actually need at the 1–10 node tier. Usually it’s just this: these actors must run on exactly one instance, with automatic failover. That doesn’t require a cluster. It requires a lease. Elarion homes its single-homed actors on whichever instance currently holds one PostgreSQL row; acquisition is a conditional upsert, renewal is the same upsert, and failover is expiry (bounded by the lease duration, immediate on graceful shutdown). “The row is the membership.” I covered the exact SQL in Just use Postgres. Seriously.
The design’s sharpest edge is a refusal: there is no transparent call forwarding. A call to a single-homed actor from the wrong instance fails loudly, with directions to the current holder. It never silently hops nodes. That’s the line in the sand, and crossing it is explicitly documented as the moment to migrate: needing transparent routing, partitioned placement, or more than roughly ten nodes is the Orleans/Akka/Proto.Actor trigger. The generated facades even mirror Orleans’ grain-interface shape (actors.Get<IMyActor>(id) ↔ grainFactory.GetGrain<IMyActor>(id), IActorState<TState> ↔ IPersistentState<TState>) so that migration is mechanical rather than a rewrite.
That’s the framing I wish I’d had in the digital-twin years, when we ran a full Akka.NET stack for what was, in hindsight, a single-writer-per-machine problem on a handful of nodes. Adopt the model early; it’s cheap, and it will simplify your code. Adopt the cluster late, only when a lease on the database you already run stops being enough.
The takeaway
The actor model isn’t WhatsApp technology; it’s a decision discipline. State that must stay consistent gets exactly one owner. The owner has a mailbox. Everyone else sends messages. You can honor that discipline with a channel and a loop, with a lease-homed runtime on plain PostgreSQL, or, when the workload truly outgrows a database row, with Orleans and its clustered machinery. Pick the smallest implementation of the discipline your scale requires, and make sure the next step up is a migration, not a rewrite. The model earns its keep at every tier; the cluster only at the last one.