Skip to content

01 · Futures & Concurrency

Everything so far has run on one thread, one step after another. Real services need to do several things at once — call a database while calling an API, handle a hundred requests concurrently instead of one at a time. Scala's answer is Future[A]: a value that represents a computation that may not have finished yet.

Future and ExecutionContext

A Future needs somewhere to actually run its work — a thread pool called an ExecutionContext. Almost every Future-returning call needs one in scope, so it's usually imported once at the top of a file:

import scala.concurrent.{Future, Await}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
import scala.util.{Success, Failure}

def slowSquare(n: Int): Future[Int] = Future {
  Thread.sleep(100)
  n * n
}

Calling slowSquare(5) returns immediately with a Future[Int] — the Thread.sleep runs on a background thread from global's pool. The caller doesn't block; it gets a placeholder for a value that will show up later.

Reacting when it's done: onComplete

val f = slowSquare(5)
f.onComplete {
  case Success(v) => println(s"got $v")
  case Failure(e) => println(s"failed: $e")
}
Thread.sleep(200) // just so this demo doesn't exit before the callback fires
// got 25

onComplete registers a callback and returns immediately — it never blocks the calling thread. In a real application you'd chain further work with map/flatMap instead of sleeping to "wait" for a side-effecting callback; the sleep above exists only so this script's output is deterministic.

Blocking when you must: Await.result

Tests, main methods, and REPL sessions sometimes need an actual value before moving on. Await.result blocks the calling thread until the Future completes or a timeout elapses:

val f2 = slowSquare(3)
val result = Await.result(f2, 2.seconds)
println(s"awaited: $result")   // awaited: 9

The trap: Await in production code

Await.result defeats the entire purpose of Future — it ties up a thread doing nothing but waiting, which is exactly what async code exists to avoid. Under load, blocking threads like this is how services exhaust their thread pool and grind to a halt. Reserve Await for main entry points, tests, and REPL exploration; production request-handling code should stay Future-based end to end via map/flatMap/for.

Composing Futures with for

Because Future has map and flatMap, for-comprehensions chain several async steps into one that runs them and combines the results:

val combined = for
  a <- slowSquare(2)
  b <- slowSquare(4)
yield a + b

println(Await.result(combined, 2.seconds))   // 20

The trap: sequential for vs. parallel start

That for-comprehension runs slowSquare(2) and then slowSquare(4) — roughly 200ms total, not 100ms. Desugared, a for calls .flatMap on the first Future, and the second Future isn't even created until the first one's callback runs. To run independent work concurrently, start every Future before the for-comprehension so both begin immediately:

val fa = slowSquare(2)   // starts now
val fb = slowSquare(4)   // starts now, concurrently with fa
val bothStarted = for
  a <- fa
  b <- fb
yield a + b
// ~100ms total, because fa and fb were already running in parallel

Handling failure

A Future whose block throws doesn't crash the program — the exception is captured as a Failure inside the Future itself:

val failing = Future { throw new RuntimeException("boom") }
try Await.result(failing, 2.seconds)
catch case e: RuntimeException => println(s"caught: ${e.getMessage}")
// caught: boom

Recover from a failed Future without unwrapping it manually using recover (supply a fallback value) or recoverWith (supply a fallback Future):

val recovered = failing.recover { case _: RuntimeException => -1 }
println(Await.result(recovered, 2.seconds))   // -1

Running many Futures together: Future.sequence

Turning a List[Future[A]] into a Future[List[A]] — running everything concurrently and collecting the results once they're all done — is common enough to have a dedicated combinator:

val many: List[Future[Int]] = List(1, 2, 3).map(slowSquare)
val allDone: Future[List[Int]] = Future.sequence(many)
println(Await.result(allDone, 2.seconds))   // List(1, 4, 9)

All three slowSquare calls start at once, so this takes roughly 100ms total rather than 300ms — the whole point of using Future in the first place.

How It Actually Works

Future[A] is a mutable box plus a callback list, guarded by synchronization, sitting on top of the JVM's ordinary thread machinery — there's no special "async" bytecode. Creating a Future(body) submits body as a Runnable/Callable-like unit of work to an ExecutionContext, which is really just a thin wrapper around a java.util.concurrent.Executor (commonly ForkJoinPool — Scala's default global ExecutionContext is backed by a work-stealing ForkJoinPool sized to the number of CPU cores). When the pool has a free worker thread, it picks the queued task off the pool's work queue and runs it. onComplete doesn't poll — it registers a callback on the Future's internal state; if the future is already complete, the callback runs (asynchronously, on the ExecutionContext) immediately, and if not, the completing thread invokes every registered callback once it finishes.

for-comprehensions over Futures desugar to flatMap exactly like Option (see Module 4), which is the mechanical reason the "sequential vs. parallel start" trap exists: for { a <- futureA(); b <- futureB() } yield ... only calls futureB() inside the lambda passed to futureA().flatMap, so futureB() (and the work it kicks off) doesn't start until futureA has already completed — the two network calls or computations run back-to-back, not concurrently, purely because of how flatMap chains callbacks. Starting both Futures as separate vals before the for avoids this because each Future(...) call submits its work to the pool immediately, independent of when you later read its result.

Await.result bridges back from async to sync by literally blocking the calling thread using java.util.concurrent primitives (a latch/condition variable under the hood) until the future's internal state transitions to complete or the timeout fires — which is exactly why doing this inside a thread the ExecutionContext's own pool needs for other work can starve the pool: you've taken one of a fixed number of worker threads and parked it doing nothing but waiting, shrinking the pool's real parallelism for every other queued task.

Cheat sheet

Need to... Use
Run work asynchronously Future { ... } (needs an ExecutionContext in scope)
React without blocking .onComplete { case Success(v) => ...; case Failure(e) => ... }
Transform a future value .map(f)
Chain futures that depend on each other .flatMap(f) or a for-comprehension
Run independent futures concurrently start them before the for, or use Future.sequence
Block for a result (tests/main only) Await.result(f, timeout)
Recover from failure .recover { case e => fallback } / .recoverWith { case e => otherFuture }
Combine a list of futures Future.sequence(listOfFutures)

Exercise

Write def fetchUser(id: Int): Future[String] and def fetchOrders(id: Int): Future[List[String]], each simulating latency with Thread.sleep inside a Future block. Write a third function def userSummary(id: Int): Future[String] that runs both concurrently (start both Futures before combining them) and combines the results into a single summary string via a for-comprehension. Use Await.result to print the summary for a couple of ids. Then make fetchUser fail for id 0 (throw inside the Future block) and use .recover so userSummary(0) prints a friendly fallback message instead of propagating the exception.