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.

Advertisement

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.

Many actors, few threads: who runs what in AkkaActor Amailbox: 3 msgsActor Bmailbox: 0 msgsActor Cmailbox: 12 msgsDefault dispatcherfork-join pool, throughput 5schedulescheduleThread 1runs A: up to 5 msgsThread 2runs C: up to 5 msgsThread 3idleBlocking dispatcherfixed pool for JDBC, filesActor Dcalls a blocking APIown dispatcherFuture callbackruns on some other threadpipeToSelf: a messageRule: actor state is touched only while the dispatcher runs that actor, one message at a time.Anything that runs on another thread must come back as a message.
Three pool threads serve many actors. A blocking actor gets its own dispatcher, and work that completes on another thread returns as a message instead of touching state directly.

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.

Advertisement

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.ask or 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

  1. Search your actor code for .foreach, .map and onComplete on futures, and replace any that touch actor state or ctx with pipeToSelf.
  2. Make every request that needs an answer carry a replyTo, and give every request whose answer matters a timeout through ctx.ask or a timer.
  3. Move multi-step conversations out of long-lived actors into per-session children that stop themselves.
  4. Find actors whose mailbox can grow without bound and put work pulling between them and their producers.
  5. Run every blocking call on a dedicated dispatcher, and grep thread dumps for default-dispatcher threads blocked in I/O.
  6. Wrap each behaviour in an explicit supervision strategy, and decide whether new work should start on Akka or Pekko.
Key takeaway: An actor owns its state and handles one message at a time on a shared dispatcher thread, and that guarantee covers only code inside the handler. Akka Typed interaction patterns exist to keep it that way: replyTo and message adapters for replies, ctx.ask for replies that must arrive, pipeToSelf to bring future results back as messages, a bounded stash while initialising, per-session children for multi-step conversations, and work pulling so producers cannot flood consumers. Isolate blocking calls on their own dispatcher and choose supervision explicitly, because typed actors stop on failure by default.