Scala: Part 16: Asynchronous Scala Without Blocking
Once I understood map and flatMap on Option, Either and Try, Scala Futures stopped looking like a separate language. The composition pattern was familiar. What changed was when the result became available, where the work ran, and what happened when I blocked the wrong thread.
This closes the Scala-only cursus I set myself within the Scala/Play series. The lab uses Scala 2.13.18 and sbt 1.12.15; Play and Pekko are not dependencies. Everything below comes from small, runnable examples with fictional insurance data, not production pricing rules.

A Future holds the result; the execution context runs the work
Coming from Java, the useful starting point was an executor. I supplied one explicitly instead of beginning with a global import:
import java.util.concurrent.Executors
import scala.concurrent.{ExecutionContext, Future}
val executor = Executors.newSingleThreadExecutor()
implicit val ec: ExecutionContext =
ExecutionContext.fromExecutorService(executor)
val premium: Future[Int] = Future {
PremiumLesson.calculatePremium(600, 3)
}
The calculation produces 550. The caller receives a Future[Int], not the integer. With this executor, the body runs on its worker. Creating the Future submits the work; Await is not what starts it. A Future is not a new thread per operation.
The implicit parameter is the compiler-supplied argument mechanism from earlier lessons. It is not Spring dependency injection. These excerpts belong inside the lab's runnable objects; the complete examples shut down their owned executors in finally. Creating a pool per request is not the intended application design.
The callback's return type tells me which method to use
A plain transformation needs map:
val label: Future[String] = premium.map((amount: Int) => {
"Annual premium: " + amount + " EUR"
})
When the next operation already returns a Future, I use flatMap:
def installment(amount: Int): Future[Int] =
Future { amount / 10 }
val nested: Future[Future[Int]] = premium.map(installment)
val composed: Future[Int] = premium.flatMap(installment)
Both comparison branches run here: the premium source is reused, but installment is called once per branch. The nested version completes with a Future handle. The composed version follows that inner Future and eventually yields 55, without blocking to extract it.
A for-comprehension expresses the same dependency:
val display: Future[String] = for {
amount <- premium
payment <- installment(amount)
} yield amount.toString + " EUR / 10 = " + payment + " EUR"
The compiler translates this into flatMap followed by map. The generator-bound values are Ints, and yield produces a plain String. The arrows hide no Await. Because installment is called inside the first success callback, it starts after that result arrives. Futures constructed before a for may already be running.

Recovery follows the same distinction
A non-fatal exception thrown in the Future body makes it fail. Downstream success callbacks are skipped. A throwing map callback, or a failed inner Future returned through flatMap, fails the derived result too. An already-started independent task is not cancelled by this.
// failedLabel has type Future[String].
val withMessage: Future[String] = failedLabel.recover {
case _: NumberFormatException => "Premium unavailable"
}
val withBackup: Future[String] = failedLabel.recoverWith {
case _: NumberFormatException => readBackupLabel("550")
}
In the lab, readBackupLabel schedules a local text-to-integer conversion and returns a Future[String]; it is not a real network call. Recover returns a plain fallback from its handler. RecoverWith follows the backup Future's outcome. Both preserve existing success and unmatched failure, as specified in the Scala 2.13.18 Future API.
If the backup also fails, its failure becomes the derived result. The same recoverWith does not recursively retry until something works. Neither method changes the original Future's outcome. I deliberately recover display text or use explicit backup input; I do not silently invent a premium amount.
A Future is not an anti-blocking spell
Wrapping a blocking Java call in Future lets the caller continue, but occupies the worker until that call returns. The clearest demonstration used our one-worker executor:
import java.util.concurrent.TimeUnit
import scala.concurrent.Await
import scala.concurrent.duration.Duration
// Deliberately bad: both tasks use the same one-worker pool.
val blocked: Future[Int] = Future {
val second = Future { 550 / 10 }
Await.result(second, Duration(200, TimeUnit.MILLISECONDS))
}
The outer task holds the only worker while waiting for the queued inner task. The finite timeout breaks the standstill. In the runnable demonstration, Try catches that timeout, the outer body releases the worker, and the queued calculation then returns 55. Timing out the wait did not cancel the task.

Our console-end waits leave the executor worker available; waits inside its tasks do not. More workers do not eliminate the general risk if all are waiting for queued dependencies. And flatMap does not rescue a blocking call placed inside its callback.
scala.concurrent.blocking is an execution-context-dependent notification, not a non-blocking adapter. Fixed Java pools do not add workers in response. Scala's blocking guidance recommends a dedicated execution context for long-lasting blocking operations. Pool sizing and production I/O remain outside this demonstration.
A successful Future can carry a business error
The final exercise reused our repository trait, typed errors and installment validation. Its result type was:
Future[Either[PolicyInputError, String]]
Read it outside-in: an asynchronous computation that returns either a business error or a label. The lookup produces Future[Option[PolicySnapshot]]. Future.map passes the Option into a synchronous business helper, whose Either for-comprehension checks the policy and installment count. Each layer composes its own container; no transformer library is involved.

We verified six input scenarios in both sync and async form, then exercised a throwing repository, explicit alternate-repository recovery, preserved business Left and display mapping. That is 16 final-lesson assertions, plus a guard proving the Left bypasses technical recovery. The demo's IllegalStateException handler is a controlled example, not a blanket production fallback policy.
The finish line is runnable
sbt "runMain learning.PremiumLesson" test
On 3 October 2026, all runnable lesson assertions and the nine existing named ScalaTest tests passed. They are separate checks: sbt test alone does not execute lesson run methods. The final source is pinned at commit 56868d0. Start with FutureBlockingLesson and ScalaConsolidationLesson.
This completes the agreed Scala course material, not a claim of independent mastery. My practical rule is now simple: keep business outcomes explicit, compose asynchronous results, and do not occupy their workers waiting for them. Play and Pekko can be separate next steps.