Concurrency: Futures, actors and effect systems

Compose asynchronous work with Future, recover from failure, use actors for stateful concurrency, and see what an effect system adds.

Future and ExecutionContext

import scala.concurrent.{Future, ExecutionContext, Await}
import scala.concurrent.duration.*
import scala.util.{Success, Failure}

given ExecutionContext = ExecutionContext.global   // use a bounded pool in production

def fetchPrice(sku: String): Future[BigDecimal] = Future {
  Thread.sleep(50)                                  // stands in for a blocking HTTP call
  BigDecimal(9.99)
}

def fetchStock(sku: String): Future[Int] = Future(3)

// a for-comprehension sequences Futures: flatMap chains them, map transforms
val summary: Future[String] =
  for
    price <- fetchPrice("ABC-1")
    stock <- fetchStock("ABC-1")
  yield s"$stock units at $price"

// run two independent Futures in parallel, not one after the other
val both: Future[(BigDecimal, Int)] =
  fetchPrice("ABC-1").zip(fetchStock("ABC-1"))

// traverse starts every element, then collects the results in order
val all: Future[List[BigDecimal]] =
  Future.traverse(List("A", "B", "C"))(fetchPrice)

// recovery belongs at the edge, where you can decide what to do
val safe: Future[String] = summary
  .recover { case e: java.io.IOException => s"unavailable: ${e.getMessage}" }
  .recoverWith { case _ => Future.successful("fallback") }

summary.onComplete {
  case Success(v) => println(v)
  case Failure(e) => println(s"failed: $e")
}

// Await only in tests and at a main method, never inside a library
Await.result(summary, 3.seconds)
  • A Future starts immediately when constructed. Building one inside a for body is the classic bug: everything runs eagerly, before the chain is composed. Use Future.defer when you need laziness.
  • Future.traverse is eager over the input collection; Future.sequence takes an existing List[Future[A]].
  • Every map, flatMap and recover needs an ExecutionContext. The global pool is fine for CPU work and wrong for blocking calls, which starve it.
  • Await.result blocks a thread and can deadlock if the pool is exhausted. Return the Future and let the caller wait.
  • A TimeoutException does not cancel the underlying work: Future has no cancellation. The thread keeps running to completion.

For blocking I/O, do not use the global pool. Either give the blocking work its own bounded executor, or use a non-blocking client. Exhausting the global pool with blocking calls is the most common way a Scala service stops responding.

Actors for stateful concurrency

// Pekko (the Apache fork of Akka) or Akka typed actors
import org.apache.pekko.actor.typed.{ActorRef, ActorSystem, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors

object Counter:
  sealed trait Command
  final case class Increment(n: Int, replyTo: ActorRef[Int]) extends Command
  final case class Get(replyTo: ActorRef[Int]) extends Command

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

  // state is a parameter: no locks, no shared mutable field
  private def active(value: Int): Behavior[Command] = Behaviors.receive { (context, message) =>
    message match
      case Increment(n, replyTo) =>
        val next = value + n
        replyTo ! next
        active(next)                       // return the new behaviour, with the new state
      case Get(replyTo) =>
        replyTo ! value
        Behaviors.same
  }

@main def run(): Unit =
  val system = ActorSystem(Counter(), "counter")
  // messages are sent, not called; the actor processes them one at a time
  system ! Counter.Increment(5, system.ignoreRef)
ConcernFutureActor
Shared stateProtected by locks or avoidedOwned by the actor, never shared
CommunicationReturn a valueSend a message
Failurerecover on the chainSupervision strategy restarts the actor
BackpressureNone built inBounded mailbox, or an explicit protocol
CompositionMonadic, staticMessages arrive one at a time, in order per sender

An actor processes one message at a time, so its state needs no synchronisation. That is the whole advantage, and the whole constraint: a blocking call inside receive blocks every other message the actor could handle.

What an effect system adds

// cats-effect: an IO value is a description; nothing runs until unsafeRunSync
import cats.effect.{IO, IOApp, Resource}
import scala.concurrent.duration.*

object App extends IOApp.Simple:
  val readFile: IO[String] = IO.blocking {
    scala.io.Source.fromFile("data.txt").mkString
  }

  // resource acquisition and release are paired by construction
  val managed: Resource[IO, java.io.BufferedReader] =
    Resource.make(IO.blocking(new java.io.BufferedReader(new java.io.FileReader("data.txt")))) { r =>
      IO.blocking(r.close())
    }

  val program: IO[Unit] =
    for
      text <- readFile
      _    <- IO.println(s"read ${text.length} characters")
      _    <- IO.sleep(100.millis)
      _    <- managed.use(r => IO.blocking(r.readLine())).flatMap(l => IO.println(l))
    yield ()

  def run: IO[Unit] = program.timeout(5.seconds).handleErrorWith(e => IO.println(s"failed: ${e.getMessage}"))
  • An effect value is a description. Nothing happens until it is run, which makes it refactorable and testable without side effects at construction.
  • Cancellation is first class: timeout actually interrupts the fibre, which Future cannot do.
  • Resource pairs acquire and release, so a file or connection is closed even when the body fails or is cancelled.
  • IO.blocking tells the runtime that a thread will be parked, so it can size the pool accordingly instead of starving it.
  • ZIO offers the same idea with a built-in environment type and richer error typing; the mental model transfers.
💡
Reach for an effect system when you need resource safety with cancellation, retries with backoff, structured concurrency or streaming. For a handful of independent calls, Future is simpler and the extra abstraction buys nothing.

FAQ

What ExecutionContext should I use?
A fixed pool sized to the CPU count for compute, and a separate bounded pool for blocking work. ExecutionContext.global is a fork-join pool with a limited compensation mechanism and it will starve under sustained blocking.
Is Future cancellable?
No. Await.result with a timeout throws, but the computation continues on its thread. For real cancellation you need an effect system, or an explicit cancellation flag checked by the work.

A small HTTP service with http4s or Play Option, Either and functional error handling

Last refreshed 2026-09-18.