Futures & Promises

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-15). scala.concurrent.Future and Promise are part of the standard library and have kept the same API since Scala 2.12, so the code also compiled and ran on the 3.9 LTS line (3.9.0, 2026-09-03) and on 3.3 LTS (3.3.8); the small differences are in Scala 3.9 LTS versus 3.10. Examples that print timings or thread names show the values observed on a 10-core machine; yours will differ.

A Future[A] is a handle to a value of type A that may not exist yet. You start a computation, get the handle back at once, and describe what to do with the result. The same idea is CompletableFuture in Java and Deferred in Kotlin coroutines; the comparison is in Comparison with Java and Kotlin. The examples use the trailhead scenario, in which a hiking toolkit queries slow weather, crowd and snow services for a trail.

ExecutionContext: who runs the work

Almost every operation on a Future that runs user code takes an ExecutionContext as a context parameter (given/using in Scala 3, implicit in Scala 2). An ExecutionContext is the abstraction over "somewhere to run a task"; Future itself never creates threads. Forgetting to provide one is the first error everybody meets.

//> using scala 3.10.0
import scala.concurrent.Future

@main def run(): Unit =
  val f = Future(1 + 1)
-- [E172] Type Error: e9.scala:5:23
5 |  val f = Future(1 + 1)
  |                       ^
  |Cannot find an implicit ExecutionContext. You might add
  |an (implicit ec: ExecutionContext) parameter to your method.
  |
  |The ExecutionContext is used to configure how and on which
  |thread pools asynchronous tasks (such as Futures) will run,
  |so the specific ExecutionContext that is selected is important.
  ...
  |The following import might fix the problem:
  |
  |  import scala.concurrent.ExecutionContext.Implicits.global

There are four common sources of an ExecutionContext.

Source What it is

ExecutionContext.global

A shared fork-join pool sized to the number of processors. import scala.concurrent.ExecutionContext.Implicits.global makes it the implicit one. Convenient for scripts and examples; in a library or a large application, pass a context explicitly.

ExecutionContext.fromExecutor(e) and fromExecutorService(e)

Wrap any Java Executor: a fixed pool, a cached pool, a virtual-thread-per-task executor.

ExecutionContext.parasitic

Runs the task on the thread that completes the future, with no scheduling. Only for tiny, non-blocking, non-throwing callbacks such as map(_ + 1).

A context you write

Extend ExecutionContext and implement execute(Runnable) and reportFailure(Throwable).

The next program runs the same kind of task on each of the first three and on parasitic, and prints where it ran.

//> using scala 3.10.0
import scala.concurrent.{Future, Await, ExecutionContext}
import scala.concurrent.duration.*
import java.util.concurrent.Executors

def where(label: String): String =
  s"$label on ${Thread.currentThread.getName} virtual=${Thread.currentThread.isVirtual}"

@main def run(): Unit =
  // 1. fixed pool
  val pool = Executors.newFixedThreadPool(2)
  given ExecutionContext = ExecutionContext.fromExecutorService(pool)
  println(Await.result(Future(where("fixed")), 1.second))
  // 2. virtual threads (JDK 21+)
  val vexec = Executors.newVirtualThreadPerTaskExecutor()
  val vec = ExecutionContext.fromExecutorService(vexec)
  println(Await.result(Future(where("virtual"))(using vec), 1.second))
  // 3. global
  println(Await.result(Future(where("global"))(using ExecutionContext.global), 1.second))
  // 4. parasitic runs on the thread that completes
  val p = scala.concurrent.Promise[Int]()
  val mapped = p.future.map { x => println(where("parasitic map")); x + 1 }(using ExecutionContext.parasitic)
  p.success(1)
  println(Await.result(mapped, 1.second))
  vexec.shutdown()
  pool.shutdown()
fixed on pool-1-thread-1 virtual=false
virtual on  virtual=true
global on scala-execution-context-global-32 virtual=false
parasitic map on main virtual=false
2

Three details are visible in the output. A virtual thread has an empty name unless you give it one. The parasitic callback ran on main, because main was the thread that called p.success(1). And an explicit (using ec) argument overrides the given in scope, which is how a single call can opt out of the default.

An executor service that you create must be shut down, otherwise its non-daemon threads keep the JVM alive. The global context uses daemon threads and needs no shutdown, which is also why a program that only uses it can exit while futures are still running.

Virtual threads on JDK 21 and later

On JDK 21 and later, Executors.newVirtualThreadPerTaskExecutor() wrapped with ExecutionContext.fromExecutorService gives every task its own virtual thread, as shown above. That suits code that blocks on I/O, because a blocked virtual thread is cheap and the blocking hint (see Blocking and Await) is not needed. It does not make CPU-bound code faster, and it does not change the Future API at all. See Java Virtual Threads for the JVM side and the pinning pitfalls; Scala adds nothing on top of them for this use.

Creating a Future

Future.apply(body) takes its body by name, schedules it on the implicit ExecutionContext at once, and returns the handle. The future is eager: it starts running as soon as you create it, whether or not anybody ever looks at the result.

//> using scala 3.10.0
import scala.concurrent.{Future, ExecutionContext}
import scala.concurrent.ExecutionContext.Implicits.global

def forecast(trail: String): Int =
  Thread.sleep(200)
  println(s"computing $trail on ${Thread.currentThread.getName}")
  trail.length * 10

@main def run(): Unit =
  println(s"main is ${Thread.currentThread.getName}")
  val f: Future[Int] = Future(forecast("Alpina"))
  println(s"isCompleted right away: ${f.isCompleted}, value: ${f.value}")
  Thread.sleep(500)
  println(s"isCompleted later: ${f.isCompleted}, value: ${f.value}")
main is main
isCompleted right away: false, value: None
computing Alpina on scala-execution-context-global-28
isCompleted later: true, value: Some(Success(60))

f.value is an Option[Try[A]]: None while the future is running, then Some(Success(a)) or Some(Failure(t)). The Try is how a future carries an exception to whoever asks for the result. The Thread.sleep(500) in main is only there so the program does not exit before the daemon-thread future finishes; the sections below show how to wait properly.

Other constructors do not need an execution context because no code runs asynchronously:

Constructor Result

Future.successful(a)

An already completed successful future.

Future.failed[A](t)

An already completed failed future.

Future.fromTry(t)

An already completed future from a Try.

Future.unit

A completed Future[Unit], shared; handy as the start of a chain.

Future.never

A future that never completes. Useful in tests and as the identity for races.

Promise[A]().future

A future that you complete by hand; see Promise.

Because Future is eager and caches its result (the body ran exactly once in the next example), a val holding a future is a running computation and not a recipe. This is the opposite of a lazy effect type such as IO or ZIO, discussed in Parallel Collections & Concurrency Libraries.

//> using scala 3.10.0
import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global

@main def run(): Unit =
  var calls = 0
  val f = Future { calls += 1; calls }
  Await.result(f, 1.second); Await.result(f, 1.second)
  println(s"body ran $calls time(s) after two awaits")
body ran 1 time(s) after two awaits

Callbacks

The lowest-level way to consume a future is to register a callback. onComplete receives the Try; foreach runs only for success; there is no dedicated failure callback because onComplete and failed.foreach cover it.

//> using scala 3.10.0
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Success, Failure}

def elevation(trail: String): Int =
  if trail.isEmpty then throw IllegalArgumentException("empty trail name")
  trail.length * 100

@main def run(): Unit =
  val ok = Future(elevation("Salewa"))
  val bad = Future(elevation(""))
  ok.onComplete:
    case Success(v) => println(s"ok: $v")
    case Failure(e) => println(s"ok failed: $e")
  bad.onComplete:
    case Success(v) => println(s"bad: $v")
    case Failure(e) => println(s"bad failed: $e")
  Thread.sleep(300)
ok: 600
bad failed: java.lang.IllegalArgumentException: empty trail name

Callbacks are registered on the implicit execution context and have three properties worth remembering:

  • A callback registered on an already completed future still runs, asynchronously, so there is no "missed it" race.

  • Several callbacks on the same future run in no guaranteed order, and may run concurrently.

  • A callback returns Unit, so it cannot feed a value onward. As soon as you need the result of the callback, use map or flatMap instead. andThen is the one exception: it runs a side effect but returns the original future unchanged.

An exception thrown inside a foreach callback is not part of any future, because the callback’s result is discarded. It is handed to ExecutionContext.reportFailure, which for the global context prints the stack trace and carries on:

//> using scala 3.10.0
import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global

@main def run(): Unit =
  val f = Future(1)
  f.foreach(_ => throw RuntimeException("callback blew up"))
  Thread.sleep(300)
  println("main continues")
  val r = for a <- Future(1); b <- Future(0) yield a / b
  println(Await.ready(r, 1.second).value)
java.lang.RuntimeException: callback blew up
	at e17$package$.run$$anonfun$1(e17.scala:9)
	...
main continues
Some(Failure(java.lang.ArithmeticException: / by zero))

The first failure is printed to standard error and lost; the second, inside yield, is captured in the resulting future, because yield is a map, which returns a future.

Transforming and combining: map, flatMap and for

map transforms the eventual value, flatMap chains a step that itself returns a future, and for/yield is syntax for the two. Together they make a Future a monad: you write straight-line code, and the library arranges for each step to run when the previous one completes. The same translation rules apply as in For Comprehensions.

//> using scala 3.10.0
import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global

def fetch(n: Int): Future[Int] = Future:
  if n % 2 == 0 then throw RuntimeException(s"station $n offline")
  n * 10

def show[A](f: Future[A]): String = Await.ready(f, 2.seconds).value.get.toString

@main def run(): Unit =
  println(show(fetch(1).map(_ + 1)))
  println(show(fetch(2).map(_ + 1)))
  println(show(fetch(1).flatMap(a => fetch(a))))
  println(show(fetch(1).zip(fetch(3))))
  println(show(fetch(1).zip(fetch(2))))
  println(show(fetch(2).failed))
  println(show(fetch(1).filter(_ > 100)))
  println(show(fetch(1).collect { case 10 => "ten" }))
  println(show(Future.successful(1).andThen { case r => println(s"andThen saw $r") }))
Success(11)
Failure(java.lang.RuntimeException: station 2 offline)
Failure(java.lang.RuntimeException: station 10 offline)
Success((10,30))
Failure(java.lang.RuntimeException: station 2 offline)
Success(java.lang.RuntimeException: station 2 offline)
Failure(scala.concurrent.Future$$anon$2: Future.filter predicate is not satisfied)
Success(ten)
andThen saw Success(1)
Success(1)

Reading the results: a failure short-circuits map and flatMap (the function is never called and the same exception comes out); fetch(1).flatMap(fetch) fails because the second call is fetch(10) and 10 is even; zip pairs two independent futures and fails if either fails; failed flips the future so that the exception becomes the value; and filter and collect turn a missed predicate into a failed future holding a NoSuchElementException. The show helper uses Await.ready only to print the result in a demo.

The sequential-versus-parallel trap

A for over futures is sequential by construction: flatMap calls its function only after the previous future succeeds, so a future that is created inside the comprehension does not start until its predecessors finish. To run independent calls concurrently, create the futures first and combine them afterwards.

//> using scala 3.10.0
import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global

def weather(trail: String): Future[Int] = Future { Thread.sleep(100); 12 }
def crowd(trail: String): Future[Int] = Future { Thread.sleep(100); 40 }
def snow(trail: String): Future[Int] = Future { Thread.sleep(100); 3 }

@main def run(): Unit =
  val t0 = System.nanoTime
  // sequential: each future is created inside the comprehension
  val seq =
    for
      w <- weather("a")
      c <- crowd("a")
      s <- snow("a")
    yield (w, c, s)
  println(Await.result(seq, 2.seconds))
  println(s"sequential ms: ${(System.nanoTime - t0) / 1_000_000}")
  val t1 = System.nanoTime
  // parallel: start all three first, combine after
  val fw = weather("a")
  val fc = crowd("a")
  val fs = snow("a")
  val par =
    for
      w <- fw
      c <- fc
      s <- fs
    yield (w, c, s)
  println(Await.result(par, 2.seconds))
  println(s"parallel ms: ${(System.nanoTime - t1) / 1_000_000}")
(12,40,3)
sequential ms: 397
(12,40,3)
parallel ms: 107

Three 100 ms calls take about 400 ms (including warm-up) in the first form and about 100 ms in the second. The difference is only where weather, crowd and snow are called: the diagram shows the second form, where three futures run at once and the for waits on them in order.

sequenceDiagram participant M as main participant EC as ExecutionContext participant W as weather participant C as crowd participant S as snow M->>EC: Future weather M->>EC: Future crowd M->>EC: Future snow par concurrent work EC->>W: run EC->>C: run EC->>S: run end M->>M: for w <- fw, c <- fc, s <- fs yield W-->>M: 12 C-->>M: 40 S-->>M: 3 M-->>M: result future completes with (12, 40, 3)

A mistake in either form is easy to make because both read identically. A rule of thumb: if the right-hand side of a <- is a method call that creates a future, the steps are sequential; if it is a previously defined val, they are concurrent. The comprehension never starts anything; it only waits and combines.

Handling failures

A failed future carries a Throwable. The combinators below turn a failure back into a value or into another future, and are the counterparts of catch in synchronous code.

//> using scala 3.10.0
import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global

def fetch(n: Int): Future[Int] = Future:
  if n % 2 == 0 then throw RuntimeException(s"station $n offline")
  n * 10

def show[A](f: Future[A]): String = Await.ready(f, 2.seconds).value.get.toString

@main def run(): Unit =
  println(show(fetch(2).recover { case _: RuntimeException => -1 }))
  println(show(fetch(2).recoverWith { case _: RuntimeException => fetch(3) }))
  println(show(fetch(2).fallbackTo(fetch(5))))
  println(show(fetch(2).transform(identity, e => IllegalStateException("wrapped", e))))
  println(show(Future.failed[Int](IllegalStateException("no"))))
Success(-1)
Success(30)
Success(50)
Failure(java.lang.IllegalStateException: wrapped)
Failure(java.lang.IllegalStateException: no)
Method Purpose

recover { case ex => value }

Replace a failure with a plain value. A PartialFunction: unmatched exceptions stay failures.

recoverWith { case ex => future }

Replace a failure with another future, for retries and fallbacks.

fallbackTo(other)

If this fails, use other; if both fail, the failure of the first is reported.

transform(f: Try[A] => Try[B])

Rewrite success and failure in one function.

transform(onSuccess, onFailure)

Two-function form, for changing the exception type while leaving values alone.

transformWith(f: Try[A] => Future[B])

Like transform but the function returns a future.

failed

A Future[Throwable] that succeeds only if this one fails.

andThen { case result => ... }

Run a side effect (logging, metrics) and pass the original result on.

recover and recoverWith take partial functions, so Future(...).recover { case _: SomeError => ... } leaves unrelated exceptions untouched. See Partial Functions.

Failures from the Scala Try machinery are the same ones used in Either & Try: a future is, conceptually, an asynchronous Try. Converting between them with Future.fromTry and future.value is occasionally useful in tests.

A retry helper

recoverWith is enough to write a retry function that reruns an operation a bounded number of times. The operation is passed by name so that every attempt starts a fresh future.

//> using scala 3.10.0
import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global

def retry[A](times: Int)(op: => Future[A]): Future[A] =
  op.recoverWith { case _ if times > 1 => retry(times - 1)(op) }

@main def run(): Unit =
  var attempts = 0
  val flaky = retry(3):
    Future:
      attempts += 1
      if attempts < 3 then throw RuntimeException(s"attempt $attempts failed") else s"ok after $attempts"
  println(Await.result(flaky, 1.second))
ok after 3

A production retry would also wait between attempts, which needs a timer; see Timeouts and cancellation.

Many futures at once

Working with a collection of futures is where the companion object helps. All of them need an implicit ExecutionContext because the combination step runs as a callback.

//> using scala 3.10.0
import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global

def level(n: Int): Future[Int] = Future:
  Thread.sleep(100 * (4 - n))
  if n == 3 then throw IllegalStateException("gauge 3 broken")
  n * 10

@main def run(): Unit =
  val ids = List(1, 2, 3, 4)
  val healthy = ids.filter(_ != 3)
  println(Await.result(Future.sequence(List(level(1), level(2))), 2.seconds))
  println(Await.result(Future.traverse(healthy)(level), 2.seconds))
  println(Await.ready(Future.sequence(ids.map(level)), 2.seconds).value)
  val all = Future.traverse(ids)(i => level(i).transform(t => scala.util.Success(t)))
  println(Await.result(all, 2.seconds))
  println(Await.result(Future.firstCompletedOf(healthy.map(level)), 2.seconds))
  println(Await.result(Future.foldLeft(healthy.map(level))(0)(_ + _), 2.seconds))
  println(Await.result(Future.reduceLeft(healthy.map(level))(_ + _), 2.seconds))
  println(Await.result(Future.find(healthy.map(level))(_ > 15), 2.seconds))
List(10, 20)
List(10, 20, 40)
Some(Failure(java.lang.IllegalStateException: gauge 3 broken))
List(Success(10), Success(20), Failure(java.lang.IllegalStateException: gauge 3 broken), Success(40))
40
70
70
Some(20)
Method Behavior

Future.sequence(futures)

Turns Iterable[Future[A]] into Future[Iterable[A]]. Fails with the first failure observed; the other futures keep running.

Future.traverse(xs)(f)

xs.map(f) followed by sequence, without building the intermediate collection. The f calls all start at once, so this is the concurrent fan-out.

Future.firstCompletedOf(fs)

Completes with the first completed result, success or failure. The slower futures are not cancelled.

Future.find(fs)(p)

The first successful value satisfying p, wrapped in Option.

Future.foldLeft(fs)(z)(op) and Future.reduceLeft(fs)(op)

Combine results in the order of the input collection.

transform(Success(_)) on each element

The idiom for "keep every outcome": it converts a failure into a Success(Failure(...)), so sequence cannot fail.

Note that Future.traverse over a very large collection starts every future immediately. To limit concurrency, process in batches or use a library with bounded parallelism (see Parallel Collections & Concurrency Libraries).

Promise

A Future is read-only. The writable side is a Promise: an object that you complete exactly once, from any thread, and whose future is observed by others. Promises are how callback-based and event-based APIs are turned into futures, and how you write your own combinators.

//> using scala 3.10.0
import scala.concurrent.{Future, Promise, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global
import java.util.concurrent.TimeoutException

@main def run(): Unit =
  val p = Promise[String]()
  val f = p.future
  println(f.isCompleted)
  println(p.trySuccess("summit"))
  println(p.trySuccess("again"))
  println(f.value)
  try p.success("third")
  catch case e: IllegalStateException => println(s"IllegalStateException: ${e.getMessage}")
  val q = Promise[Int]()
  q.completeWith(Future(42))
  println(Await.result(q.future, 1.second))
  val slow = Promise[Int]().future
  try Await.result(slow, 300.millis)
  catch case e: TimeoutException => println(s"TimeoutException: ${e.getMessage}")
  val z = Promise[Int]()
  z.failure(new RuntimeException("boom"))
  println(z.future.value)
  println(Promise.successful(1).future.value)
false
true
false
Some(Success(summit))
IllegalStateException: Promise already completed.
42
TimeoutException: Future timed out after [300 milliseconds]
Some(Failure(java.lang.RuntimeException: boom))
Some(Success(1))
Method Behavior

success(a), failure(t), complete(try)

Complete the promise; throw IllegalStateException if it is already completed.

trySuccess(a), tryFailure(t), tryComplete(try)

Return true if this call completed it, false if somebody else had. Use these when several completers race.

completeWith(future) and tryCompleteWith

Complete from another future’s outcome.

Promise.successful(a) and Promise.failed(t)

Already completed promises.

Because only one completer can win, a promise is a safe building block for a first-wins race. A promise that is never completed gives a future that never completes, so Await.result on it reports a timeout, as in the example.

A typical use is to adapt a callback API. The following wraps a hypothetical listener-style service:

import scala.concurrent.{Future, Promise}

trait TrailService:
  def load(name: String, onDone: Int => Unit, onError: Throwable => Unit): Unit

def loadLength(service: TrailService, name: String): Future[Int] =
  val p = Promise[Int]()
  service.load(name, p.success, p.failure)
  p.future

This fragment was not run here because TrailService stands for a third-party callback API; the pattern is the standard way to bridge such APIs. Use trySuccess/tryFailure if the library might call both handlers.

Blocking and Await

Await.result(future, timeout) and Await.ready(future, timeout) block the calling thread until the future completes. Both require a finite Duration. result returns the value or rethrows the failure; ready returns the future itself, so you inspect value.

Blocking is the one thing you should do rarely. It belongs in main, in a test, or at the boundary with a synchronous API; never inside another future’s body and never inside a callback. If you must block inside a future, tell the execution context with scala.concurrent.blocking.

//> using scala 3.10.0
import scala.concurrent.{Future, Await, blocking}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global

@main def run(): Unit =
  val cores = Runtime.getRuntime.availableProcessors
  println(s"cores: $cores")
  def burst(label: String)(body: => Unit): Unit =
    val t0 = System.nanoTime
    val fs = (1 to cores * 2).map(_ => Future(body))
    Await.result(Future.sequence(fs), 30.seconds)
    println(s"$label: ${(System.nanoTime - t0) / 1_000_000} ms for ${cores * 2} sleeps of 500 ms")
  burst("plain Thread.sleep   ")(Thread.sleep(500))
  burst("blocking { sleep }   ")(blocking(Thread.sleep(500)))
cores: 10
plain Thread.sleep   : 1032 ms for 20 sleeps of 500 ms
blocking { sleep }   : 506 ms for 20 sleeps of 500 ms

The global pool has as many threads as cores, so twenty blocking sleeps on 10 cores need two rounds (about 1 s). Wrapping the sleep in blocking { ... } lets the pool add compensating threads, and all twenty overlap (about 0.5 s). blocking only informs the pool; it does nothing for a fixed pool, where the extra tasks queue. The Await methods call blocking internally for the same reason.

Starvation and deadlock

Blocking inside a future on a bounded pool can deadlock. With a single-thread executor, an outer future that awaits an inner one holds the only thread, and the inner task can never run:

//> using scala 3.10.0
import scala.concurrent.{Future, Await, ExecutionContext}
import scala.concurrent.duration.*
import java.util.concurrent.Executors

@main def run(): Unit =
  val one = Executors.newSingleThreadExecutor()
  given ExecutionContext = ExecutionContext.fromExecutor(one)
  val outer = Future:
    val inner = Future(21)
    Await.result(inner, 500.millis) * 2   // blocks the only thread the inner task needs
  println(Await.ready(outer, 2.seconds).value)
  one.shutdown()
Some(Failure(java.util.concurrent.TimeoutException: Future timed out after [500 milliseconds]))

The timeout is what saved the program; with Duration.Inf it would hang forever. Compose with flatMap instead of awaiting, and keep the blocking calls out of the pool that runs the non-blocking work, for example by giving blocking I/O a separate executor or a virtual-thread executor.

Timeouts and cancellation

Future has no built-in timeout combinator and no cancellation. Cancelling the observer does not stop the underlying work, and a "timeout" only changes what the caller sees. The usual construction races the future against a timer through a Promise:

//> using scala 3.10.0
import scala.concurrent.{Future, Promise, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global
import java.util.concurrent.{Executors, TimeUnit, TimeoutException}

val timer = Executors.newSingleThreadScheduledExecutor: r =>
  val t = Thread(r, "trailhead-timer")
  t.setDaemon(true)
  t

extension [A](f: Future[A])
  def within(d: FiniteDuration): Future[A] =
    val p = Promise[A]()
    timer.schedule((() => p.tryFailure(TimeoutException(s"gave up after $d"))): Runnable, d.toMillis, TimeUnit.MILLISECONDS)
    f.onComplete(p.tryComplete)
    p.future

def slowFuture(ms: Long): Future[String] = Future { Thread.sleep(ms); "done" }

@main def run(): Unit =
  println(Await.ready(slowFuture(50).within(500.millis), 2.seconds).value)
  println(Await.ready(slowFuture(1500).within(300.millis), 2.seconds).value)
Some(Success(done))
Some(Failure(java.util.concurrent.TimeoutException: gave up after 300 milliseconds))

The extension method (see Extension Methods) wins the race with tryFailure or tryComplete, whichever happens first. The slow task keeps running to completion in the background. If you need to stop work, you must design for it: check an AtomicBoolean, interrupt a thread you own, or use a library whose fibers or scopes are cancellable (see Parallel Collections & Concurrency Libraries).

Fatal errors never complete a future

Future bodies catch only non-fatal exceptions. Future uses scala.util.control.NonFatal, which excludes VirtualMachineError (so StackOverflowError and OutOfMemoryError), ThreadDeath, LinkageError, InterruptedException and ControlThrowable. The treatment is not uniform, as the next program shows.

//> using scala 3.10.0
import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.boundary

def probe(label: String)(body: => Int): Unit =
  val f = Future[Int](body)
  val r =
    try Await.ready(f, 700.millis).value.toString
    catch case e: java.util.concurrent.TimeoutException => "never completed (TimeoutException from Await)"
  println(f"$label%-22s -> $r")

@main def run(): Unit =
  probe("RuntimeException")(throw RuntimeException("boom"))
  probe("InterruptedException")(throw InterruptedException("stop"))
  probe("NotImplementedError")(???)
  probe("boundary.break")(boundary { boundary.break(5) })
  probe("StackOverflowError")(throw StackOverflowError())
  probe("OutOfMemoryError")(throw OutOfMemoryError("fake"))
  probe("LinkageError")(throw LinkageError("fake"))
RuntimeException       -> Some(Failure(java.lang.RuntimeException: boom))
InterruptedException   -> Some(Failure(java.util.concurrent.ExecutionException: Boxed Exception))
NotImplementedError    -> Some(Failure(java.util.concurrent.ExecutionException: Boxed Exception))
boundary.break         -> Some(Success(5))
StackOverflowError     -> never completed (TimeoutException from Await)
OutOfMemoryError       -> never completed (TimeoutException from Await)
LinkageError           -> never completed (TimeoutException from Await)

InterruptedException and NotImplementedError (???) are boxed in an ExecutionException so that they can be delivered as a failure; ControlThrowable`s such as the one used by `boundary.break are converted into the break’s value; the three JVM-level errors escape to the thread’s uncaught exception handler (the LinkageError stack trace printed to standard error during the run is omitted above), and the future stays pending forever. The practical consequence: always give Await a timeout and treat an unexplained hang as a possible fatal error in a future body.

Interoperating with CompletableFuture

scala.jdk.FutureConverters converts both ways. The Java side is what you get from Java libraries and from the JDK HTTP client.

//> using scala 3.10.0
import scala.concurrent.{Future, Await}
import scala.concurrent.duration.*
import scala.concurrent.ExecutionContext.Implicits.global
import scala.jdk.FutureConverters.*
import java.util.concurrent.CompletableFuture

@main def run(): Unit =
  val cf: CompletableFuture[String] = CompletableFuture.supplyAsync(() => "from java")
  val sf: Future[String] = cf.asScala
  println(Await.result(sf.map(_.toUpperCase), 1.second))
  val back: CompletableFuture[Int] = Future(7).asJava.toCompletableFuture
  println(back.get())
FROM JAVA
7

asScala turns a CompletionStage into a Future; asJava returns a CompletionStage, and toCompletableFuture gives the mutable class. Java-side callers see the full details in Calling Scala from Java.

Testing code that returns futures

Prefer returning the future from the test and letting the framework wait for it. MUnit does this out of the box: a test body whose last expression is a Future is awaited and its failure fails the test.

//> using scala 3.10.0
//> using test.dep org.scalameta::munit::1.2.1
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

class FutureSuite extends munit.FunSuite:
  test("a future returned from a test is awaited"):
    Future(21 * 2).map(n => assertEquals(n, 42))

  test("a failing future fails the test"):
    Future(1).map(n => assertEquals(n, 2))
FutureSuite:
  + a future returned from a test is awaited 0.01s
==> X FutureSuite.a failing future fails the test  0.013s munit.ComparisonFailException: FutureSuite.test.scala:11
10:  test("a failing future fails the test"):
11:    Future(1).map(n => assertEquals(n, 2))
values are not the same
=> Obtained
1
=> Diff (- expected, + obtained)
-2
+1
Error: munit tests failed.

In plain code (a main, a script, a test framework without async support), Await.result(f, 5.seconds) is acceptable at the edge. The test frameworks are covered in MUnit Async, Fixtures & Other Frameworks. To avoid real sleeps in tests of timing logic, inject the ExecutionContext and a scheduler.

Dependencies and build files

Future, Promise, Await and ExecutionContext are in the standard library, so neither a Scala CLI //> using dep directive nor an sbt libraryDependencies entry is needed. The converters used above (scala.jdk.FutureConverters) are also in the standard library since Scala 2.13.

Comparison with Java and Kotlin

  • Java’s CompletableFuture is the closest analogue: thenApply is map, thenCompose is flatMap, exceptionally is recover. A CompletableFuture is also eager, but it is mutable (anyone can complete it, which collapses Scala’s Future and Promise into one class) and it can be cancelled in name only. Scala’s separation of the read-only Future from the writable Promise is the main design difference. Java’s modern alternatives are virtual threads and structured concurrency; see Java Virtual Threads.

  • Kotlin uses coroutines, which are cancellable and structured, so a scope’s children are cancelled with the parent, and suspend functions look sequential. A Scala Future has none of that, but it also needs no compiler support and no coloured functions. See Kotlin Coroutines Basics.

  • JavaScript’s Promise is eager too, but is single-threaded and runs callbacks on the event loop; a Scala Future runs on a thread pool and so needs ExecutionContext and thread-safe shared state.

  • Python’s asyncio tasks are cooperatively scheduled and cancellable; a Scala Future is preemptively scheduled by the pool and not cancellable.

Where the books are outdated

  • Material written for Scala 2.10 and 2.11 passes the context as implicit ec: ExecutionContext and calls Future { ... } with braces. Both still work in Scala 3, but idiomatic Scala 3 uses using clauses and braceless blocks as shown here, and import ExecutionContext.Implicits.global is still the fastest way to get started.

  • Older tutorials describe onSuccess and onFailure. They were deprecated in Scala 2.12 and are gone from the 2.13 and 3 library; use foreach, onComplete, andThen and recover.

  • Books from the Scala 2.9 and 2.10 era that recommend scala.actors or scala.concurrent.ops.spawn describe APIs that no longer exist in the current library. Actor-style programming today uses a library such as Pekko; see Parallel Collections & Concurrency Libraries.

Scala 3.9 LTS versus 3.10

The Future API is a standard-library matter. Its API is the Scala 2.13 one on every Scala 3 release; up to 3.7 the Scala 3 library depends on scala-library 2.13.x, and since 3.8 the same API is compiled by Scala 3 and versioned with the compiler. Nothing in this page depends on the compiler version. The examples above were also compiled and run on 3.3.8 and 3.9.0 with the same results. Two things are worth knowing:

  • Braceless Future: blocks, extension methods and using clauses need Scala 3.0 or later; on Scala 2.13 write Future { ... }, implicit class and implicit ec. The core program (for a <- Future(1); b <- Future(2) yield a + b with an App) compiled and printed 3 on Scala 2.13.18.

  • The compiler error for the missing ExecutionContext is E172 with the same wording on 3.3.8, 3.9.0 and 3.10.0.