Akka Ask vs Tell: Choosing the Right Communication Pattern
Choosing between Akka's tell and ask patterns determines whether your actor system stays responsive or blocks under load. This post explains the overhead of ask, when tell is sufficient, and how to verify the impact with a profiler.
11 Mar 2026, 16:13 UTC

The Core Problem: Blocking vs. Non-Blocking in Actor Systems
Every Akka developer faces a decision when two actors need to communicate: use tell (fire-and-forget) or ask (request-response). The choice directly impacts throughput, latency, and system stability. tell is the default for a reason—it keeps the actor model’s non-blocking promise intact. ask introduces a temporary actor and a Future, which adds overhead and forces timeout management. This post breaks down when each pattern fits and how to avoid the common pitfalls that turn a responsive system into a blocked one.
Tell: The Default for High-Throughput Messaging
tell (or !) sends a message asynchronously without waiting for a reply. The sender continues immediately, and the receiver processes the message when scheduled. This pattern preserves loose coupling and maximizes throughput because no temporary objects or promise chains are created.
// Akka Typed (Scala 2.13+, Akka 2.6+)
import akka.actor.typed.scaladsl.Behaviors
import akka.actor.typed.{ActorRef, Behavior}
object Worker {
sealed trait Command
final case class DoWork(taskId: String, replyTo: ActorRef[Result]) extends Command
final case class Result(taskId: String, output: String)
def apply(): Behavior[Command] = Behaviors.receiveMessage {
case DoWork(taskId, replyTo) =>
// Simulate work
val result = s"processed-$taskId"
replyTo ! Result(taskId, result) // tell back to sender
Behaviors.same
}
}
object Manager {
def apply(worker: ActorRef[Worker.Command]): Behavior[Worker.Result] =
Behaviors.receiveMessage { case Worker.Result(id, out) =>
println(s"Manager received result for $id: $out")
Behaviors.same
}
}
In this snippet, the Manager sends a DoWork message via tell and includes its own ActorRef for the reply. The Worker responds with another tell. No Future is created, and the Manager remains free to handle other messages.
Ask: When You Need a Response
ask (or ?) is necessary when the caller must have a result to proceed—typical for HTTP gateways, gRPC endpoints, or orchestration actors that aggregate replies. Internally, ask spawns a short-lived promise actor that wraps the response into a Future/CompletionStage. This actor is stopped once the reply arrives or the timeout expires.
import akka.actor.typed.scaladsl.AskPattern._
import akka.util.Timeout
import scala.concurrent.Future
import scala.concurrent.duration._
implicit val timeout: Timeout = 3.seconds
implicit val scheduler = system.scheduler
implicit val ec = system.executionContext
val futureResult: Future[Worker.Result] = worker.ask(ref => Worker.DoWork("task-42", ref))
futureResult.foreach { result =>
println(s"Gateway got result: ${result.output}")
}
The ask call returns a Future that completes when the Worker replies to the temporary actor. The Timeout is mandatory; without it the Future would never fail if the Worker crashes or stalls.
Trade-offs: Latency, Memory, and Complexity
- Overhead: Each
askallocates a promise actor and aPromiseobject. Under high load (thousands of concurrent requests) this increases GC pressure and heap usage. - Timeout tuning: A too-short timeout causes spurious failures; a too-long timeout ties up the promise actor and its mailbox, leaking memory. The research brief warns that improper timeout configuration can lead to resource leaks under heavy load.
- Blocking risk: Calling
Await.resulton the returnedFutureblocks the calling thread, defeating the actor model’s concurrency model. Never do this in actor code or on the dispatcher that runs actors. - Chaining complexity: Multiple dependent
askcalls produce nestedflatMapchains that are harder to read and debug than a lineartellflow with explicit reply handling.
Worked Example: Manager Delegating to Worker
Consider a Manager that must process a batch of tasks. For each task it can either:
- Tell: Send
DoWorkand handle replies asynchronously in its own message handler. Best when order doesn’t matter and the manager doesn’t need to wait. - Ask: Use
askinside aFuture.sequenceto collect all results before replying to an external client. Necessary when the HTTP layer demands a single aggregated response.
Sample ask aggregation (run in a gateway actor, not in the Manager itself):
def handleBatch(taskIds: List[String]): Future[List[Worker.Result]] = {
val futures = taskIds.map(id => worker.ask(ref => Worker.DoWork(id, ref)))
Future.sequence(futures)
}
Run this in a controller or a dedicated gateway actor that is allowed to block on the Future (via onComplete or map) because it does not share the actor dispatcher. Verify the result by checking that the HTTP response latency stays within the SLA and that the JVM profiler (e.g., JFR, VisualVM) shows no growing number of PromiseActorRef instances.
Limitations and Practical Verification
The ask pattern’s temporary actors are invisible in code but visible in heap dumps. A practical check: run a load test with 10k concurrent ask requests, take a heap dump, and filter for akka.actor.typed.internal.PromiseActorRef. If the count matches the concurrency level, the pattern is working as designed; if it grows unbounded, timeouts are not firing or replies are lost. Also monitor thread dumps for threads stuck in Await.result—a sign of blocking misuse.
Actionable Guidelines
- Default to
tellfor all internal actor-to-actor communication. - Use
askonly at system boundaries (HTTP, gRPC, CLI) where a synchronous response is required. - Set timeouts per use-case, not globally; prefer short timeouts (seconds) with circuit-breaker logic.
- Never
Await.resultinside an actor’s behavior or on the actor dispatcher. - Profile memory and thread state when scaling
askbeyond a few hundred concurrent requests.
By respecting the asymmetry—tell for flow, ask for boundaries—you keep the actor system responsive and the code maintainable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.