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.