Akka is an implementation of the actor model, and the actor model is a specific answer to a specific question: how do you write concurrent programs without shared mutable state? The answer is that you do not share state at all. Each actor owns its data privately, communicates only by sending immutable messages, and processes those messages strictly one at a time. Inside an actor you are single-threaded and can write ordinary sequential code with no locks, no volatile fields and no memory-visibility reasoning; between actors there is nothing to lock because there is nothing shared. What Akka adds on top of that core is the machinery that makes it usable at scale -- supervision hierarchies that turn failure into a first-class concern, dispatchers that multiplex millions of actors onto a handful of threads, and a cluster layer that lets the same programming model span machines.
Actors, mailboxes, and the one-message-at-a-time rule
An actor is three things: private state, a behaviour that defines how it reacts to each message type, and a mailbox that queues incoming messages. You never hold a reference to the actor object itself -- only an ActorRef, which is a handle you can send to. That indirection is what makes location transparency possible later: the reference may point at an actor in this JVM or on another machine, and the sending code is identical.
Sending is asynchronous and returns immediately. The message lands in the mailbox; the dispatcher eventually schedules the actor onto a thread; the actor processes messages from its mailbox sequentially. Because exactly one thread runs an actor's behaviour at a time, mutable fields inside an actor need no synchronisation.
The guarantees are precise and worth memorising, because assuming stronger ones is a reliable source of bugs. Delivery is at-most-once: Akka does not retry, so a message lost to a network failure is simply lost, and anything that must not be lost needs acknowledgements or persistence. Ordering is guaranteed per sender-receiver pair: if A sends 1 then 2 to B, B sees 1 before 2. There is no ordering guarantee across different senders, and no global ordering at all.
Messages must be immutable. Nothing enforces it -- sending a mutable object works, and then two actors share it, and you have reintroduced exactly the problem the model exists to remove. In Scala this means case classes; in Java, records or carefully final-ised classes.
Typed actors — the current API
Akka has two APIs. The classic one takes Any as its message type and matches with a partial function, so sending the wrong message compiles fine and fails at runtime as an unhandled message. The typed API, which is the one to learn, parameterises the reference: an ActorRef[Command] accepts only Command values, and the compiler rejects everything else.
object Counter {
sealed trait Command
final case class Increment(by: Int) extends Command
final case class GetValue(replyTo: ActorRef[Value]) extends Command
final case class Value(n: Int)
def apply(): Behavior[Command] = counting(0)
private def counting(n: Int): Behavior[Command] =
Behaviors.receiveMessage {
case Increment(by) => counting(n + by)
case GetValue(replyTo) =>
replyTo ! Value(n)
Behaviors.same
}
}Two idioms in that snippet carry most of the style. State is held as a parameter of the behaviour rather than as a mutable field, and changing state means returning a new behaviour -- which makes the state machine explicit and testable. And a request that expects an answer carries its own replyTo reference typed to the response, because in a typed system the sender is not implicitly available; the protocol has to name where the reply goes.
Behaviors.setup wraps construction that needs the actor context -- spawning children, scheduling timers, obtaining the logger. Behaviors.receive gives access to that context per message. Behaviors.same, Behaviors.stopped and returning a different behaviour are how an actor stays put, terminates, or transitions.
Hierarchy and supervision — let it crash
Actors form a tree. The actor system has a user guardian at the root, and every actor an actor spawns becomes its child. This is not organisational tidiness; it is the failure model.
When an actor throws, it does not handle its own failure. The exception is signalled to its parent, which decides what happens: resume and keep the current state, restart and rebuild from the initial behaviour, or stop the child entirely. In the typed API the decision is expressed by wrapping the behaviour:
Behaviors.supervise(Connection(host))
.onFailure[IOException](
SupervisorStrategy.restartWithBackoff(
minBackoff = 1.second, maxBackoff = 30.seconds, randomFactor = 0.2))The philosophy behind it -- 'let it crash' -- is frequently misquoted as 'do not handle errors'. What it actually says is that expected errors are modelled in your protocol as messages, while unexpected ones should not be patched over with defensive code that leaves the actor in an unknown state. Restarting to a known-good state is more reliable than trying to reason about every corrupt intermediate. The parent, which is not the failing component, is in a better position to decide.
Exponential backoff in the restart strategy matters more than it looks. A plain restart against an unreachable database produces a hot loop that restarts thousands of times a second and buries the real error in log noise; backoff with jitter turns that into a manageable retry. Alongside supervision sits death watch: an actor can watch another and receive a Terminated signal when it stops, which is how peers -- rather than parents -- react to a dependency disappearing.
Dispatchers, and the blocking mistake
A dispatcher is the thread pool that runs actors. Millions of actors share a pool sized to the number of cores, because an actor consumes a thread only while it is processing a message. An idle actor costs a few hundred bytes and no thread at all, which is why an actor per user session or per device is a reasonable design.
That efficiency rests entirely on actors not blocking. When an actor performs a blocking call -- a JDBC query, a synchronous HTTP request, Thread.sleep, Await.result on a future -- it occupies a pool thread while doing nothing. A handful of such actors exhausts the default dispatcher, and then every actor in the system stops being scheduled. The symptom is a system-wide freeze with low CPU usage, and it is comfortably the most common serious Akka production failure.
There are two correct responses. Prefer non-blocking APIs and pipe the resulting future back to the actor as a message, so the actor never waits. Where a blocking API is unavoidable, isolate it on its own dispatcher configured with a fixed thread pool, so the damage is bounded to that pool:
blocking-io-dispatcher {
type = Dispatcher
executor = "thread-pool-executor"
thread-pool-executor { fixed-pool-size = 16 }
throughput = 1
}Mailboxes are configurable too. The default is unbounded, which means a slow consumer accumulates messages until the heap runs out -- an unbounded mailbox converts backpressure into an out-of-memory error. Bounded mailboxes make the problem visible instead, priority mailboxes reorder by importance, and control-aware mailboxes let management messages jump the queue behind a backlog of work.
Talking to actors from outside — tell, ask, and adapters
tell -- written ! -- is fire-and-forget and is the default. It is cheap, it never blocks, and it composes with the model. Design protocols so that responses come back as further messages rather than as return values, and most of the system needs nothing else.
ask exists for the boundary where a request genuinely needs a response: an HTTP route that must produce a body, or a test. It allocates a short-lived internal actor to receive the reply, returns a Future, and requires an explicit timeout. Two rules keep it safe: never use it as the routine way for actors to talk to each other, because every ask has a timeout that will eventually fire under load and produce a cascade of spurious failures; and never block on the resulting future inside another actor, which is the blocking trap from the previous section wearing a disguise.
Within actors, the typed equivalent is context.ask, which takes the future's eventual success or failure and maps it into a message delivered back to the asking actor. The actor stays non-blocking and single-threaded, and the response arrives through the mailbox like everything else. The related tool is the message adapter, which converts another actor's response protocol into your own command type -- the standard way to keep an actor's public message type sealed while still receiving replies from components that know nothing about it.
Akka Streams and backpressure
Actors give asynchrony but not flow control: an unbounded mailbox will happily accept messages faster than the actor can process them. Akka Streams sits on top of actors and adds demand-driven backpressure, implementing the Reactive Streams specification -- a consumer signals how much it can take, and that demand propagates upstream all the way to the source, which then simply does not produce more.
The vocabulary is small: a Source produces, a Flow transforms, a Sink consumes, and connecting them yields a blueprint that does nothing until it is materialised. Materialisation is when actors are actually created and the stream begins to run, and it returns a value -- often a future of the result, or a handle to stop the stream.
Source(1 to 1000000)
.mapAsync(parallelism = 8)(id => fetchRecord(id))
.filter(_.isActive)
.groupedWithin(500, 1.second)
.mapAsync(4)(batch => writeBatch(batch))
.runWith(Sink.ignore)That pipeline processes a million records with bounded memory, bounded concurrency, and automatic batching -- properties that would take substantial care to reproduce with raw actors. In practice most Akka applications are streams at the edges, where data flows and rate control matter, and actors in the middle, where state and protocol matter. The two interoperate directly: a stream can have an actor as its source or sink, and an actor can run a stream.
Clustering — the same model across machines
Because code addresses actors through references rather than objects, the model extends across a network with no change to the programming style. Akka Cluster forms a membership group over a gossip protocol, with no master: nodes join through configured seed nodes, agree on membership, and detect failures with a phi-accrual failure detector that adapts to observed heartbeat latency rather than using a fixed timeout.
The hard problem in any such system is the network partition, where each side believes the other is dead. Resolving it wrongly gives two halves both acting as the whole cluster -- split brain -- which for stateful actors means two instances of the same entity accepting conflicting writes. Akka ships a split brain resolver with several strategies (keep the majority, keep the side containing a designated role, keep the oldest node) and the important operational fact is that you must choose one deliberately and understand what it sacrifices. A cluster without a configured resolution strategy is not safe for stateful work.
On top of membership sit the tools people actually use. Cluster sharding distributes entity actors across nodes by entity identifier, keeps at most one instance of each entity alive cluster-wide, routes messages to wherever it lives, and rebalances shards when nodes join or leave -- the standard way to model millions of stateful entities like accounts or devices. Cluster singleton guarantees exactly one instance of an actor in the cluster, for coordination roles. Distributed Data replicates state using CRDTs, which converge without coordination and are therefore available during partitions.
Persistence and event sourcing
Actor state lives in memory, so a restart loses it. Akka Persistence answers that by event sourcing: the actor persists the events that caused each state change to a journal, and on restart it replays them to rebuild state. The current state is a fold over history rather than a stored row.
An EventSourcedBehavior is defined by two functions -- a command handler that validates a command and decides which events to emit, and an event handler that applies an event to state. The separation is strict and load-bearing: command handling may reject, but event handling must be total and deterministic, because it runs again on every replay. Any side effect placed in the event handler happens again on recovery, which is how a system ends up re-sending last month's emails after a restart.
Replaying from the beginning of time gets slow, so snapshots checkpoint state periodically and recovery starts from the latest snapshot plus subsequent events. Journals are pluggable -- Cassandra, JDBC and R2DBC being the common choices -- and the journal's durability characteristics are your system's durability characteristics.
The pairing with cluster sharding is the canonical Akka architecture: sharding guarantees a single live instance per entity, persistence guarantees that instance can recover its state anywhere in the cluster. Read models are built with projections that consume the event stream into query-optimised stores, which is CQRS arrived at by necessity rather than by fashion -- an event journal is a poor thing to query directly.
Testing actor systems
Asynchrony makes naive assertions flaky, so Akka provides testing tools that make the concurrency explicit rather than hoping it settles in time.
The ActorTestKit runs a real actor system in the test and supplies test probes: actors that record what they receive and let you assert on it with a timeout. Because typed protocols carry an explicit replyTo, a probe drops naturally into the place a collaborator would occupy, which makes interaction testing straightforward without mocking frameworks.
val probe = testKit.createTestProbe[Counter.Value]()
val counter = testKit.spawn(Counter())
counter ! Counter.Increment(5)
counter ! Counter.GetValue(probe.ref)
probe.expectMessage(Counter.Value(5))For pure behaviour logic there is a synchronous BehaviorTestKit that runs a behaviour without threads at all, letting you assert on the effects it produced -- spawned children, scheduled timers, messages sent. It is fast and deterministic, and it cannot test anything involving real timing.
Two habits prevent most flakiness: assert on messages with generous timeouts rather than sleeping, and configure a virtual or manual-time scheduler when testing timeout-driven logic instead of waiting for wall-clock time to pass.
The licence change, and Pekko
In 2022 Akka moved from Apache 2.0 to the Business Source Licence, starting with version 2.7. BSL is source-available rather than open source: the code is public and usable, but production use by organisations above a published revenue threshold requires a commercial licence, and each release converts to Apache 2.0 after a fixed period of years. Development, testing and use by smaller organisations remained free under the vendor's terms.
The community response was Apache Pekko, a fork of the last Apache-licensed Akka release donated to the Apache Software Foundation. Pekko is Apache 2.0, carries the same architecture and near-identical APIs with package names changed from akka to org.apache.pekko, and covers the corresponding modules -- streams, HTTP, cluster, persistence. Migration from Akka 2.6 is largely mechanical.
For a decision today the split is clean. If you want a permissive licence with community governance and no revenue test, Pekko. If you want commercial support, the newest features and the vendor's tooling and are prepared to license it, Akka. Either way, evaluate the licence before the architecture -- discovering the constraint after building on it is a far more expensive conversation, and this is the single most important non-technical fact about Akka as a dependency.
When the actor model fits, and when it does not
Akka is a strong fit for systems with lots of independent, stateful, concurrent things: connected devices, game sessions, trading accounts, order workflows, chat rooms. It fits domains where failure is expected and isolation matters, where entity state benefits from living in memory rather than being loaded per request, and where the same programming model spanning one node and twenty is genuinely valuable.
It is a poor fit for request-response CRUD over a database, which is most applications. There the actor model adds a concurrency abstraction to a problem that does not have a concurrency problem, and the framework's real operational demands -- dispatcher tuning, cluster formation, split-brain configuration, journal operation -- buy nothing. It also asks a lot of a team: the model is unfamiliar, the failure modes are unfamiliar, and a partially-understood cluster is worse than a straightforward stateless service.
The nearby alternatives are worth knowing honestly. In Scala, effect systems such as Cats Effect and ZIO cover concurrency with a typed, composable model that many teams find easier to reason about, without actors. For stateful stream processing, Kafka Streams or Flink solve the distribution and state problem with a different and often simpler operational story. And if the actor model itself is what appeals, the BEAM languages implement it as a runtime rather than a library, with preemptive scheduling that removes the blocking hazard entirely.