Skip to content

09 · Akka Actors Basics

Future (module 01) models one asynchronous value. Akka actors model asynchronous, stateful objects that process one message at a time, never share mutable state with each other, and communicate only by sending messages — a different, complementary answer to "how do I write concurrent code without locks and race conditions."

build.sbt
scalaVersion := "3.3.1"
libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % "2.8.5"

The actor model in one sentence

An actor has: a mailbox (a queue of incoming messages), a behavior (what to do with the next message), and the ability to send messages to other actors, create new actors, or change its own behavior for the next message. Crucially, an actor processes messages one at a time, in order — there's no way for two messages to run concurrently against the same actor's state, which is what eliminates the shared-mutable-state race conditions that make raw multithreading hard.

Defining and running a first actor

import akka.actor.typed.{ActorSystem, Behavior}
import akka.actor.typed.scaladsl.Behaviors

object Greeter:
  sealed trait Command
  case class Greet(name: String) extends Command

  def apply(): Behavior[Command] = Behaviors.receive { (ctx, msg) =>
    msg match
      case Greet(name) =>
        println(s"Hello, $name!")
        Behaviors.same
  }

val system = ActorSystem(Greeter(), "greeter-system")
system ! Greeter.Greet("Ada")
Thread.sleep(200)
system.terminate()
// Hello, Ada!

Every actor's protocol is a sealed trait of Commands it accepts — Akka Typed (the modern API) enforces this at compile time, so you can't accidentally send an actor a message its behavior doesn't handle. ! (the "tell" operator) sends a message and returns immediately — actor communication is fire-and-forget by default, same spirit as onComplete on a Future. Behaviors.same means "keep this exact behavior for the next message" — the actor doesn't change how it responds.

The trap: actors don't run synchronously

That Thread.sleep(200) after system ! Greeter.Greet("Ada") exists only because this is a main method that would otherwise exit before the message is processed on its own thread. ! never blocks and never guarantees the message has been handled by the time the next line runs — production code reacts to actor results via further messages or the ask pattern below, never by sleeping and hoping.

Actors with internal state

An actor "changes behavior" by returning a different Behavior value from its message handler — this is how actors hold state without mutable vars, using recursion instead:

import akka.actor.typed.ActorRef

object Counter:
  sealed trait Command
  case object Increment extends Command
  case class GetCount(replyTo: ActorRef[Int]) extends Command

  def apply(): Behavior[Command] = counting(0)

  private def counting(count: Int): Behavior[Command] = Behaviors.receive { (ctx, msg) =>
    msg match
      case Increment =>
        counting(count + 1)          // returns a NEW behavior closing over count + 1
      case GetCount(replyTo) =>
        replyTo ! count
        Behaviors.same
  }

Each Increment doesn't mutate a field — it returns counting(count + 1), a fresh behavior value that closes over the incremented count and becomes what handles the next message. There's no var anywhere, yet the actor is stateful — this is the same "state as an immutable value passed forward" idea as a recursive accumulator, just driven by incoming messages instead of a loop.

Getting an answer back: the ask pattern

GetCount above carries an ActorRef[Int] — a reference the actor can reply to. Wiring that up manually gets verbose, so Akka provides ask, which returns a Future for the reply:

import akka.actor.typed.scaladsl.AskPattern._
import akka.util.Timeout
import scala.concurrent.duration._
import scala.concurrent.Await

counterSystem ! Counter.Increment
counterSystem ! Counter.Increment
counterSystem ! Counter.Increment

given Timeout = 3.seconds
given akka.actor.typed.Scheduler = counterSystem.scheduler

val countFuture: Future[Int] = counterSystem.ask[Int](replyTo => Counter.GetCount(replyTo))
val count = Await.result(countFuture, 3.seconds)
println(s"count: $count")   // count: 3

ask needs an implicit Timeout (in case the actor never replies) and a Scheduler in scope, and returns a Future[Int] that completes when the actor sends its reply — bridging the actor world back into the Future world from module 01 whenever you need one specific answer rather than an ongoing stream of messages.

The trap: ask everywhere defeats the point of actors

Reaching for ask (and blocking on the resulting Future) for every interaction reintroduces synchronous, request/response thinking into a system designed around asynchronous message flows — and each ask has real overhead (a temporary actor to receive the reply, a timeout). Prefer plain ! (tell) and modeling the response as another message to a known recipient wherever the caller doesn't need to block for a single answer; reserve ask for edges of the actor system, like an HTTP handler that genuinely needs a value before it can respond to its own caller.

How It Actually Works

Every actor owns a mailbox — by default an unbounded (or configurably bounded) queue that other threads/actors push messages onto — and the guarantee that "actors don't run synchronously" comes from how the ActorSystem's dispatcher schedules work against that mailbox. A dispatcher is, under the hood, backed by a thread pool (often a ForkJoinPool, the same executor family behind Future's ExecutionContext in Module 1) that doesn't dedicate one thread per actor — instead, when a message lands in an idle actor's mailbox, the dispatcher schedules a small unit of work ("process up to N queued messages from this mailbox") onto whichever pool thread is free. This is the actual mechanism behind Akka's core safety guarantee: an actor processes its mailbox one message at a time, never concurrently with itself, because the dispatcher only ever has at most one such scheduled unit of work active per actor at once — so an actor's mutable internal state (the pattern in "actors with internal state") never needs locks or synchronization; the single-message-at-a-time scheduling is the concurrency control.

tell (!) is fire-and-forget because it does nothing more than enqueue the message object onto the target's mailbox and return immediately — no thread blocks waiting for processing. The ask pattern (?) is built entirely on top of tell plus Future: internally, ask creates a short-lived, hidden temporary actor whose only job is to receive exactly one reply and complete a Promise (the mutable, settable counterpart to a Future) with it, sends your message to the target with that temporary actor's address as the sender, and returns the Future half of that promise to you immediately. This is precisely why "ask everywhere defeats the point": each ask call allocates a temporary actor and a Future, and forces the caller back into blocking-or-callback territory around each request/response pair — the overhead and back-pressure loss tell-based message-passing was designed to avoid in the first place.

Cheat sheet

Need to... Use
Define an actor's message protocol a sealed trait of case classes/objects extending Command
Define behavior Behaviors.receive { (ctx, msg) => ... } returning a Behavior[Command]
Start an actor system ActorSystem(rootBehavior, "name")
Send a message, don't wait actorRef ! message
Keep the same behavior for the next message Behaviors.same
Hold state without var return a new behavior value closing over updated state
Get one reply as a Future actorRef.ask[Reply](replyTo => Message(replyTo)) (needs Timeout + Scheduler)
Shut everything down system.terminate()

Exercise

Build a Bank actor with commands Deposit(amount: Int), Withdraw(amount: Int, replyTo: ActorRef[Boolean]) (replies false and leaves the balance unchanged if funds are insufficient), and GetBalance(replyTo: ActorRef[Int]). Model the balance as recursive-behavior state, the same way Counter does. Start an ActorSystem, deposit some amount, attempt an over-limit withdrawal and confirm via ask it returns false with the balance unchanged, then withdraw a valid amount and confirm the balance via ask.