Skip to main content
Scala intermediate Lesson 7 of 10

Futures and Asynchronous Composition

Compose asynchronous work with map and for-comprehensions, run independent calls in parallel, and avoid the mistake that makes a concurrent pipeline sequential.

A Future[A] is a value that will exist later, or a failure. You never wait for one; you describe what to do when it arrives, and the runtime does the waiting.

Starting work

import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global

case class Customer(id: Int, name: String, country: String)

def fetchCustomer(id: Int): Future[Customer] = Future:
  Thread.sleep(300)                       // pretend network call
  Customer(id, s"Customer $id", "GB")

@main def run(): Unit =
  val f = fetchCustomer(1)
  println(s"started, completed = ${f.isCompleted}")

  f.foreach(c => println(s"arrived: ${c.name}"))

  println(Await.result(f, 2.seconds))
started, completed = false
arrived: Customer 1
Customer(1,Customer 1,GB)

Future { ... } submits the block to the execution context and returns immediately — isCompleted is false on the next line. foreach registers a callback.

Await.result blocks until the value arrives. It belongs in a main or a test and nowhere else: blocking a thread to wait for asynchronous work removes the reason to use futures at all.

Transforming without blocking

def fetchOrders(customerId: Int): Future[List[Double]] = Future:
  Thread.sleep(200)
  List(25.50, 12.00, 40.00)

@main def run(): Unit =
  val total: Future[String] =
    fetchCustomer(1)
      .flatMap(c => fetchOrders(c.id).map(os => (c, os)))
      .map((c, os) => f"${c.name}: ${os.sum}%.2f across ${os.size} orders")

  println(Await.result(total, 2.seconds))
Customer 1: 77.50 across 3 orders

map transforms the eventual value; flatMap chains a step that is itself asynchronous. Exactly the same shape as Option in lesson 4, and the same for-comprehension works:

  val total = for
    customer <- fetchCustomer(1)
    orders   <- fetchOrders(customer.id)
  yield f"${customer.name}: ${orders.sum}%.2f"
Customer 1: 77.50

The mistake

That for-comprehension is correct — fetchOrders genuinely needs the customer first. But apply the same shape to independent calls and you lose all your concurrency:

def timed[A](label: String)(body: => A): A =
  val t0 = System.currentTimeMillis()
  val r = body
  println(f"$label%-12s ${System.currentTimeMillis() - t0} ms")
  r

@main def run(): Unit =
  timed("sequential"):
    val f = for
      a <- fetchCustomer(1)
      b <- fetchCustomer(2)
      c <- fetchCustomer(3)
    yield List(a, b, c).map(_.name)
    Await.result(f, 5.seconds)

  timed("parallel"):
    val fa = fetchCustomer(1)
    val fb = fetchCustomer(2)
    val fc = fetchCustomer(3)
    val f = for
      a <- fa
      b <- fb
      c <- fc
    yield List(a, b, c).map(_.name)
    Await.result(f, 5.seconds)
sequential   906 ms
parallel     303 ms

Three 300ms calls: 906ms one way, 303ms the other. The only difference is where the futures are created. In the first, fetchCustomer(2) is inside the callback of the first future, so it cannot start until that completes. In the second, all three are already running before the for-comprehension combines them.

This is the single most common Scala concurrency bug, and it is invisible — the code looks concurrent and the results are correct.

Many futures at once

@main def run(): Unit =
  val ids = (1 to 10).toList

  val all: Future[List[Customer]] = Future.traverse(ids)(fetchCustomer)
  println(timed("traverse")(Await.result(all, 5.seconds)).size)

  val futures: List[Future[Customer]] = ids.map(fetchCustomer)
  val sequenced: Future[List[Customer]] = Future.sequence(futures)
  println(timed("sequence")(Await.result(sequenced, 5.seconds)).size)
traverse     311 ms
sequence     305 ms
10
10

Future.traverse maps and collects in one step; Future.sequence turns a List[Future[A]] you already have into a Future[List[A]]. Both fail as soon as any element fails.

To keep the successes, catch failures per element first:

  val settled: Future[List[Either[Throwable, Customer]]] =
    Future.traverse(ids)(id => fetchCustomer(id).map(Right(_)).recover { case e => Left(e) })

Failure

def fetchRate(country: String): Future[Double] = Future:
  if country == "XX" then throw new NoSuchElementException(s"no rate for $country")
  0.20

@main def run(): Unit =
  val ok = fetchRate("GB").recover { case _: NoSuchElementException => 0.0 }
  val bad = fetchRate("XX").recover { case _: NoSuchElementException => 0.0 }

  println(Await.result(ok, 1.second))
  println(Await.result(bad, 1.second))

  val chained = fetchRate("XX").recoverWith { case _ => fetchRate("GB") }
  println(Await.result(chained, 1.second))

  fetchRate("XX").onComplete {
    case scala.util.Success(r) => println(s"rate $r")
    case scala.util.Failure(e) => println(s"failed: ${e.getMessage}")
  }
  Thread.sleep(200)
0.2
0.0
0.2
failed: no rate for XX

A failure propagates through map and flatMap untouched, so an unhandled one surfaces wherever you finally look at the result. recover supplies a value, recoverWith supplies another future, and onComplete sees both outcomes as a Try.

The trap: a future whose failure nobody inspects fails silently.

@main def run(): Unit =
  fetchRate("XX").map(r => println(s"never printed: $r"))
  Thread.sleep(500)
  println("main finished with no error shown")
main finished with no error shown

No stack trace, no warning. Always terminate a chain with onComplete, a recover, or by returning the future to something that will.

Timeouts

import java.util.concurrent.{Executors, TimeUnit}

def withTimeout[A](f: Future[A], after: FiniteDuration): Future[A] =
  val timeout = Promise[A]()
  val scheduler = Executors.newSingleThreadScheduledExecutor()
  scheduler.schedule(
    () => timeout.tryFailure(new java.util.concurrent.TimeoutException(s"timed out after $after")),
    after.toMillis, TimeUnit.MILLISECONDS)
  Future.firstCompletedOf(List(f, timeout.future))
    .andThen { case _ => scheduler.shutdown() }

@main def run(): Unit =
  val slow = Future { Thread.sleep(2000); "done" }
  println(Await.result(withTimeout(slow, 300.millis).recover { case e => e.getMessage }, 3.seconds))
timed out after 300 milliseconds

Future.firstCompletedOf takes whichever finishes first. A Promise is the write side of a future — you complete it yourself, which is how you bridge callback-based APIs into futures.

Note that the slow future keeps running; a timeout abandons the result, it does not cancel the work.

The execution context

@main def run(): Unit =
  val blocking = Future:
    Thread.sleep(1000)
    "waited"
  println(Await.result(blocking, 2.seconds))

ExecutionContext.global sizes itself to your cores. Fill it with blocking I/O and everything else starves — eight blocking calls on an eight-core machine and no other future can run.

Give blocking work its own pool:

import java.util.concurrent.Executors
given blockingEc: ExecutionContext =
  ExecutionContext.fromExecutor(Executors.newFixedThreadPool(64))

Or mark the region so the pool can compensate:

import scala.concurrent.blocking
Future { blocking { Thread.sleep(1000) }; "waited" }

For new code, Scala 3 on JDK 21+ also gives you virtual threads, which make blocking cheap again — but a Future on a fixed pool still blocks that pool, so the rule above holds unless the executor is virtual-thread backed.

Practice

1. Run three futures sequentially and then in parallel.
sequential   906 ms
parallel     303 ms

Three times the latency from one structural difference. Whenever a for-comprehension over futures is slower than expected, check whether the futures are created inside it.

2. Use Future.traverse over a list of ids.
println(Await.result(Future.traverse((1 to 10).toList)(fetchCustomer), 5.seconds).size)
10

Ten 300ms calls in about 310ms. Note this submits all ten at once — for a large list, batch with grouped or the remote service will be the one that complains.

3. Fail a future and recover from it.
println(Await.result(fetchRate("XX").recover { case _ => 0.0 }, 1.second))
0.0

Then remove the recover and re-run: Await.result throws the original exception. Without a blocking wait, the same failure would have been swallowed entirely.

4. Ignore a failed future and see what is reported.
main finished with no error shown

Nothing. The future failed and no one asked, so the exception went nowhere. Every chain needs a terminal onComplete, a recover, or to be returned to a caller that handles it.

Next: testing — MUnit, ScalaTest, and property-based tests.

Frequently Asked Questions

What is an ExecutionContext in Scala?
The thread pool a `Future` runs on. Every operation on a `Future` needs one in implicit scope. `ExecutionContext.global` is a work-stealing pool sized to your cores — fine for CPU-bound work, wrong for blocking I/O, which needs its own pool.
Why does my for-comprehension over Futures run sequentially?
Because a for-comprehension is `flatMap`, so each future is created inside the previous one's callback and cannot start until it completes. Start the futures as `val`s before the for-comprehension, then combine them, and they run in parallel.
Should I use Await.result?
Only at the edge of an application — a `main` method or a test — where something has to block. Inside a service it ties up a thread and destroys the benefit of asynchrony. Compose with `map` and `flatMap` and return the `Future` instead.
How do I handle a failed Future?
`recover` supplies a fallback value for matching exceptions, `recoverWith` supplies a fallback `Future`, and `transform` handles both outcomes. A failure inside `map` propagates rather than throwing at the call site, so nothing is lost if you forget — it just surfaces later.