The actor pattern replaces shared memory and locks with a simpler rule: each actor owns its state, and the only way to affect that state is to send the actor a message, which it handles one at a time. That rule removes data races by construction. It does not remove concurrency problems; it moves them into how actors talk to each other. A request that never gets a reply, a future callback that mutates state from the wrong thread, or a fast producer that floods a slow consumer are all interaction bugs, and Akka has a named pattern for each.
This page explains those patterns from the threading side, with Akka Typed code in Scala: which thread runs your code, why some patterns exist only to get back onto that thread, and how to keep a system of actors responsive under load. The runtime itself, mailboxes, fairness and supervision intensity, is in actor model architecture, and a first typed system built step by step is in Scala Akka actors introduction. Everything here also applies to Apache Pekko with a package rename.
The one-thread rule and the dispatcher
An Akka actor is not a thread. It is an object with a mailbox and a current behaviour. When a message arrives, the actor is scheduled on a dispatcher, a thread pool shared by many actors. A pool thread takes the actor, processes up to throughput messages from its mailbox (5 by default for the default dispatcher), then releases it so other actors get a turn. The next batch may run on a different thread.
Akka guarantees that an actor processes one message at a time and that each run happens-after the previous one, so state written while handling message 1 is visible while handling message 2 even if a different thread runs it. That is the whole safety argument, and it holds only for code that runs inside the message handler. Code that runs anywhere else, in a future callback, a scheduled task or another library thread, has no such guarantee.
Tell, and request-response with replyTo
The basic interaction is fire-and-forget: ref ! msg, called tell. It returns immediately, never blocks and gives no confirmation. When you need an answer, Akka Typed makes it explicit in the protocol: the request carries the reference the reply should go to.
object Inventory {
sealed trait Command
final case class Reserve(sku: String, qty: Int, replyTo: ActorRef[Reply]) extends Command
sealed trait Reply
final case class Reserved(sku: String) extends Reply
final case class OutOfStock(sku: String, available: Int) extends Reply
def apply(stock: Map[String, Int]): Behavior[Command] =
Behaviors.receiveMessage { case Reserve(sku, qty, replyTo) =>
val have = stock.getOrElse(sku, 0)
if (have >= qty) {
replyTo ! Reserved(sku)
apply(stock.updated(sku, have - qty)) // new state is a new behaviour
} else {
replyTo ! OutOfStock(sku, have)
Behaviors.same
}
}
}Note what is absent: no lock, no synchronized, no atomic. The map is immutable and the new state is returned as the next behaviour. Delivery is at most once, and order is preserved only per sender-receiver pair, so a protocol that needs confirmation has to say so, as replyTo does here.
Adapting replies into your own protocol
An actor's reference has one message type. If an Order actor wants replies from Inventory, it cannot give out its own ActorRef[Order.Command] because Inventory sends Inventory.Reply. The fix is a message adapter: a function, registered once, that wraps the foreign reply into one of your own commands.
object Order {
sealed trait Command
final case class Place(sku: String, qty: Int) extends Command
private final case class InventoryReplied(r: Inventory.Reply) extends Command
def apply(inventory: ActorRef[Inventory.Command]): Behavior[Command] =
Behaviors.setup { ctx =>
val adapter: ActorRef[Inventory.Reply] = ctx.messageAdapter(InventoryReplied(_))
Behaviors.receiveMessage {
case Place(sku, qty) =>
inventory ! Inventory.Reserve(sku, qty, adapter)
Behaviors.same
case InventoryReplied(Inventory.Reserved(sku)) =>
ctx.log.info("reserved {}", sku); Behaviors.same
case InventoryReplied(Inventory.OutOfStock(sku, n)) =>
ctx.log.warn("{} short, {} left", sku, n); Behaviors.same
}
}
}The adapter runs in the receiving actor, so the wrapped reply goes through the mailbox like any other message, and the one-thread rule holds. Keep the wrapper message private; it is an implementation detail, not part of the public protocol.
Ask between actors, with a timeout
Plain request-response has a gap: if the reply never comes, nothing tells you. ctx.ask closes it by attaching a timeout and turning the outcome, reply or timeout, into one message to yourself.
implicit val timeout: Timeout = 3.seconds
ctx.ask(inventory, (ref: ActorRef[Inventory.Reply]) => Inventory.Reserve(sku, qty, ref)) {
case Success(reply) => InventoryReplied(reply)
case Failure(e) => ReserveTimedOut(sku, e) // also a private Command
}Use ask when exactly one reply is expected and its absence must be handled. The cost is a short-lived temporary actor per request, so prefer plain tell with an adapter on hot paths where loss is tolerable or handled another way. From code that is not an actor, an HTTP handler for instance, the AskPattern import gives ref.ask(...) returning a Future; never block on it with Await inside an actor.
pipeToSelf: never touch state from a future
This is the bug that the actor pattern was supposed to make impossible, and it is the most common one in real Akka code. An actor calls an asynchronous API and updates its state in the callback:
// WRONG: the callback runs on another thread, concurrently with the actor
var cache = Map.empty[String, Price]
Behaviors.receiveMessage { case Lookup(sku) =>
priceClient.fetch(sku).foreach(p => cache += sku -> p) // data race
Behaviors.same
}The foreach callback runs on whatever thread completes the future, while the actor may be processing another message on a pool thread. Both mutate cache with no happens-before relation. The fix is pipeToSelf, which converts the completed future into a message, so the update happens inside the handler:
Behaviors.receiveMessage {
case Lookup(sku) =>
ctx.pipeToSelf(priceClient.fetch(sku)) {
case Success(p) => PriceLoaded(sku, p)
case Failure(e) => PriceFailed(sku, e)
}
Behaviors.same
case PriceLoaded(sku, p) => withCache(cache.updated(sku, p))
case PriceFailed(sku, e) => ctx.log.warn("price {} failed", sku, e); Behaviors.same
}The same rule applies to ctx itself: the ActorContext is not thread-safe, so do not call ctx.log, ctx.spawn or ctx.self from inside a callback either. Capture ctx.self into a val first if you must send from elsewhere.
Stash while initialising
Many actors cannot serve requests until they have loaded something: configuration, a snapshot, a connection. Messages that arrive meanwhile should neither be dropped nor answered wrongly. A stash buffer holds them, and the actor replays them once ready.
def apply(loader: Loader): Behavior[Command] =
Behaviors.withStash(capacity = 1000) { buffer =>
Behaviors.setup { ctx =>
ctx.pipeToSelf(loader.load()) {
case Success(s) => Loaded(s)
case Failure(e) => LoadFailed(e)
}
Behaviors.receiveMessage {
case Loaded(state) => buffer.unstashAll(active(state))
case LoadFailed(e) => throw e // let supervision decide
case other => buffer.stash(other); Behaviors.same
}
}
}The capacity is mandatory and deliberate: a stash is an in-memory queue, and if loading takes long under heavy traffic it fills. Stashing beyond capacity throws, so decide whether that should crash the actor or whether you should check buffer.isFull and reply with a busy error instead.
Per-session children and aggregation
When one request needs several steps or several replies, put the conversation in a short-lived child actor rather than piling correlation maps into a long-lived parent. The child holds the state for one session, replies to the original requester, and stops itself. An aggregator is the most common case: ask several actors, collect replies until all have answered or a timer fires, then send one combined reply.
def quote(sku: String, suppliers: Seq[ActorRef[GetPrice]], replyTo: ActorRef[Quote]): Behavior[Msg] =
Behaviors.setup { ctx =>
Behaviors.withTimers { timers =>
suppliers.foreach(_ ! GetPrice(sku, ctx.self))
timers.startSingleTimer(Deadline, 500.millis)
def collecting(got: List[Price]): Behavior[Msg] = Behaviors.receiveMessage {
case p: Price if got.size + 1 == suppliers.size =>
replyTo ! Quote(sku, p :: got); Behaviors.stopped
case p: Price => collecting(p :: got)
case Deadline => replyTo ! Quote(sku, got); Behaviors.stopped // partial answer
}
collecting(Nil)
}
}The parent spawns one of these per incoming request with ctx.spawnAnonymous and forgets about it. Timers are cancelled automatically when the actor stops, and because the timeout is just another message, there is no race between the last reply and the deadline: whichever is processed first wins, and the other is dropped as a dead letter.
Work pulling: backpressure between actors
Tell never blocks, which is the actor pattern's great strength and its main operational danger. A producer that sends faster than a consumer handles fills the consumer's mailbox, and the default mailbox is unbounded, so memory grows until the JVM fails. Bounded mailboxes drop or block instead, neither of which is usually what you want.
Work pulling inverts the flow: workers ask for work when they are ready, and the producer sends only when asked. Each worker starts by sending Ready(self); the producer keeps a queue of tasks and a queue of idle workers and pairs them; a worker sends Ready again after finishing. The number of in-flight tasks is bounded by the number of workers, and the producer can see its own backlog and push back on its source. Akka ships a reliable version of this as WorkPullingProducerController in its reliable delivery module; the hand-rolled version above is enough to understand it and for many in-process uses. The same idea appears as demand signalling in streams and as bounded queues in channels.
Blocking calls and dispatchers
A handler that blocks, a JDBC query, a file read, Thread.sleep, holds a pool thread for the duration. The default dispatcher has a pool sized from the core count, so a few blocking actors can occupy all of them, and every other actor in the system stops making progress even though CPUs are idle. The symptom is latency across unrelated actors and thread dumps full of threads parked in socket reads.
Prefer non-blocking clients and pipeToSelf. Where blocking is unavoidable, isolate it: define a dispatcher with a fixed thread pool and spawn the blocking actors on it, so they can only starve each other.
# application.conf
blocking-io-dispatcher {
type = Dispatcher
executor = "thread-pool-executor"
thread-pool-executor { fixed-pool-size = 16 }
throughput = 1
}ctx.spawn(JdbcWriter(), "jdbc-writer", DispatcherSelector.fromConfig("blocking-io-dispatcher"))
Supervision defaults and failure modes
In Akka Typed an actor that throws is stopped by default, unlike classic actors, which restarted. Choose explicitly by wrapping the behaviour, for example Behaviors.supervise(b).onFailure[IOException](SupervisorStrategy.restartWithBackoff(1.second, 30.seconds, 0.2)). A restart rebuilds the behaviour from its factory, so in-memory state is lost and must be rebuilt. Watchers learn about a stop through the Terminated signal after ctx.watch(ref).
- State touched from a callback: intermittent wrong values that never reproduce in tests. Fix with
pipeToSelf. - Unbounded mailbox growth: rising heap and latency in one actor. Fix with work pulling or by sharding the hot actor.
- Lost replies: a request with no timeout waits forever. Fix with
ctx.askor a timer. - Dispatcher starvation: everything slows when one component blocks. Fix with a separate dispatcher.
- Ask chains: actors asking actors asking actors, each with its own timeout, produce cascading timeouts. Keep timeouts decreasing down the chain, or restructure as tell with a per-session child.
- Deadlock by design: two actors that each wait, with stash, for the other to answer first. Actors cannot deadlock on locks, but they can on protocols; see deadlock detection.
Akka or Pekko, and when not to use actors
Akka 2.7 and later are licensed under the Business Source License, which requires a commercial licence for many production uses. Apache Pekko is a fork of Akka 2.6 under the Apache 2.0 licence, with the same typed API under org.apache.pekko package names. Check your licence position before starting a new system; the patterns on this page are identical in both.
Actors fit long-lived entities with their own state and lifecycle: devices, sessions, accounts, workflows. They fit poorly for plain request handling with no state, for CPU-bound data parallelism, and for code that mostly calls blocking libraries; there, a thread pool, a stream library or structured concurrency is simpler and easier to debug.
What to do next
- Search your actor code for
.foreach,.mapandonCompleteon futures, and replace any that touch actor state orctxwithpipeToSelf. - Make every request that needs an answer carry a
replyTo, and give every request whose answer matters a timeout throughctx.askor a timer. - Move multi-step conversations out of long-lived actors into per-session children that stop themselves.
- Find actors whose mailbox can grow without bound and put work pulling between them and their producers.
- Run every blocking call on a dedicated dispatcher, and grep thread dumps for default-dispatcher threads blocked in I/O.
- Wrap each behaviour in an explicit supervision strategy, and decide whether new work should start on Akka or Pekko.