Every Scala developer who has used Future has been told not to block on it, and nearly every codebase still contains Await.result. Both are reasonable. A program has to turn its asynchronous result into something synchronous at least once, in main, in a test, or at the edge of a synchronous interface. The trouble starts when the same call moves into request handlers, actors or library code running on a shared pool, and a service that passed its tests stalls under load.
This page explains what Await does, line by line, in the Scala 2.13 standard library, which Scala 3 also uses. It covers the difference between ready and result, how timeouts and Duration values behave, what blocking does and does not do for you, a reproducible deadlock, what actually gets thrown, and the few places where Await belongs. For futures and execution contexts in general, see Scala Futures and ExecutionContexts; for completing futures from callbacks, see Scala Promise.
Why Await is an object, not a method on Future
A Future[T] extends Awaitable[T], which declares two methods, ready(atMost) and result(atMost). Both take an implicit CanAwait permit, and user code cannot create one. The only public way to call them is through scala.concurrent.Await, which supplies the permit. The design makes blocking deliberate and easy to find: if you search a codebase for Await., you find every place a thread waits for a future.
Await has exactly two methods. Await.result(f, d) waits up to d and returns the value, or throws the exception the future failed with. Await.ready(f, d) waits the same way but returns the future itself, completed, and never throws its failure. Afterwards you inspect f.value, which is a Some(Try). Use ready when a failure is an expected outcome you want as data, for example when a test checks that a call fails. Use result when a failure should propagate as an exception.
import scala.concurrent.{Await, Future}
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext.Implicits.global
val f: Future[Int] = Future { Thread.sleep(200); 42 }
val n: Int = Await.result(f, 2.seconds) // 42, or throws the failure, or TimeoutException
val g: f.type = Await.ready(f, 2.seconds) // the same future, now completed; never throws its failure
g.value // Some(Success(42))Both methods throw TimeoutException if the deadline passes and InterruptedException if the waiting thread is interrupted. Neither changes the future. A timeout ends the wait, not the work.
What happens inside one call
In 2.13 both methods begin with a fast path. If the future is already complete, Await.result returns its value or throws its exception without involving the thread pool at all. Waiting on an already-finished future is therefore cheap, which matters in code that checks results after an earlier Future.sequence has finished.
Otherwise the call is wrapped in blocking { ... }. The blocking call asks the current BlockContext how to run the wait. On a worker thread of the global ExecutionContext, the BlockContext is the ForkJoin worker itself. It wraps the wait in a ForkJoinPool.ManagedBlocker, so the pool can start a compensating thread and keep its target parallelism while this one is parked. On any other thread, such as your main thread, a fixed thread pool or an Akka or Pekko dispatcher, the default BlockContext simply runs the wait. No extra thread is started.
The wait itself registers a small latch on the future through onComplete with the parasitic context, then parks the thread on that latch. A finite timeout parks with a nanosecond deadline. Duration.Inf parks indefinitely and can still be interrupted. When the future completes, the callback releases the latch, the thread wakes, and the result is returned or thrown.
Durations: Inf, zero, negative and Undefined
The atMost argument is a scala.concurrent.duration.Duration, and four kinds of value behave differently.
| atMost | Future already complete | Future still running |
|---|---|---|
Finite and positive, e.g. 5.seconds | returns at once | parks for up to 5 s, then TimeoutException |
Duration.Zero or negative | returns at once | TimeoutException immediately, without parking |
Duration.Inf | returns at once | parks until completion or interrupt |
Duration.Undefined | IllegalArgumentException | IllegalArgumentException |
Zero is useful as a non-blocking check: Await.result(f, Duration.Zero) either has the value or fails fast. The negative case matters when a program shares one time budget across several sequential waits. A Deadline gives each wait the time that is left, and once the budget is spent, the remaining waits fail immediately instead of each waiting its own full timeout.
import scala.concurrent.duration._
val deadline = 3.seconds.fromNow
val user = Await.result(fetchUser(id), deadline.timeLeft)
val prefs = Await.result(fetchPrefs(id), deadline.timeLeft) // timeLeft may be negative: fails at onceAvoid Duration.Inf outside tests that have an external timeout. A future that never completes, because a callback was lost or because of the fatal-error case below, turns an infinite wait into a hung process with no error message.
What actually gets thrown
If the future failed with an ordinary exception, Await.result rethrows that same exception object. It is not wrapped, so catch { case e: IOException => ... } works as written. There are three exceptions to that rule, and each one surprises people.
First, when a future fails with an Error, an InterruptedException or a ControlThrowable, the standard library replaces the failure with java.util.concurrent.ExecutionException and keeps the original as its cause. That includes NotImplementedError, which ??? throws, so a stubbed method reached in a test surfaces as an ExecutionException. If you catch it, unwrap getCause.
Second, a truly fatal throwable thrown inside a Future { ... } body, such as OutOfMemoryError or another VirtualMachineError, is not captured. The library rethrows it on the pool thread and never completes the future. A waiter with Duration.Inf hangs forever, while a waiter with a finite timeout eventually reports a TimeoutException that hides the real cause. The cause only shows up in the pool's uncaught-exception output, so look there.
Third, a timeout throws java.util.concurrent.TimeoutException, which scala.concurrent re-exports under the same name. NonFatal catches the timeout but deliberately not InterruptedException. Do not widen the catch to Throwable to get it. An interrupt means someone wants the thread to stop, so restore the interrupt flag or let the exception propagate.
Thread starvation: a deadlock you can reproduce
The serious danger of Await is not the time the caller spends waiting. It is that the waiting thread may be the one needed to run the work it is waiting for. The program below uses a pool of two threads. Each parent future takes a worker and blocks on a child future, and the children sit in the queue behind the parents. Nothing can make progress until the five-second timeouts fire. With Duration.Inf the program hangs permanently.
import java.util.concurrent.Executors
import scala.concurrent._
import scala.concurrent.duration._
implicit val ec: ExecutionContext =
ExecutionContext.fromExecutorService(Executors.newFixedThreadPool(2))
def child(i: Int): Future[Int] = Future(i * 2)
def parent(i: Int): Future[Int] = Future {
Await.result(child(i), 5.seconds) // holds a worker while the child waits in the queue
}
val all = Future.sequence((1 to 2).map(parent))
Await.result(all, 10.seconds) // TimeoutException after about 5 seconds
// The fix: compose instead of waiting inside the pool.
def parentFixed(i: Int): Future[Int] = child(i).map(_ + 0)On the global ExecutionContext the same program usually works, because each parked worker is replaced by a compensating thread. That hides the problem rather than solving it. Compensation is capped by scala.concurrent.context.maxExtraThreads, which defaults to 256. Under load, a service that blocks in every request first creates hundreds of threads, each with its own stack, and then, once the cap is reached, behaves exactly like the fixed pool. Compensation also applies only on the global pool's own workers. A Future that you map onto a custom executor, an HTTP server's worker pool or an actor dispatcher gets none.
The rule follows from this. Never call Await from code running on a pool that the awaited work may also need. In practice that means never calling it from inside a Future body, a map or flatMap callback, an actor's receive, or a request handler on an asynchronous server. Compose with flatMap, zip, Future.sequence or a for-comprehension instead, so no thread waits at all.
Where Await is the right tool
There are four legitimate homes for Await, and they share one property: the calling thread belongs to you and does nothing else.
- The program entry point. A command-line tool or batch job builds a future-based pipeline, then waits once in
mainwith a generous finite timeout and maps each outcome to an exit code. - Tests. Waiting keeps assertions simple. ScalaTest's
ScalaFuturestrait providesfutureValueandwhenReadywith a configurable patience, and MUnit lets a test return a Future directly. Both are clearer than raw Await, and both fail with a readable message. - A synchronous interface you must implement. If a framework requires a method that returns
T, for example a JDBC-style driver interface or a legacy plugin API, and your implementation is asynchronous, Await is the bridge. Keep it on the framework's own calling thread, give it a timeout, and document that the method blocks. - Orderly shutdown. A shutdown hook waiting a bounded time for in-flight work to drain before the JVM exits.
A well-shaped entry point looks like this. The timeout is finite, each outcome has its own exit code, and the boxed case is unwrapped so the log shows the real cause.
import java.util.concurrent.{ExecutionException, TimeoutException}
import scala.concurrent.{Await, ExecutionContext, Future}
import scala.concurrent.duration._
import scala.util.control.NonFatal
object Main {
def main(args: Array[String]): Unit = {
implicit val ec: ExecutionContext = ExecutionContext.global
val run: Future[Report] = Pipeline.run(args) // all the real work is non-blocking
val code =
try { println(Await.result(run, 10.minutes).summary); 0 }
catch {
case e: TimeoutException => System.err.println(s"gave up: $e"); 2
case e: ExecutionException => System.err.println(s"failed: ${e.getCause}"); 1
case NonFatal(e) => System.err.println(s"failed: $e"); 1
}
sys.exit(code)
}
}
Timeouts without blocking
A common reason to reach for Await in service code is to impose a timeout on a slow call. You can have the timeout without the blocked thread. Complete a Promise from a scheduler, and race it against the real future. Akka and Pekko provide after for this, and Cats Effect and ZIO have built-in timeout operators. On the plain standard library, a few lines are enough.
import java.util.concurrent.{Executors, ScheduledExecutorService, TimeUnit, TimeoutException}
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.concurrent.duration.FiniteDuration
object Timeouts {
private val timer: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { r =>
val t = new Thread(r, "future-timeouts"); t.setDaemon(true); t
}
/** Fails after d without blocking any thread. Does not cancel the underlying work. */
def within[T](f: Future[T], d: FiniteDuration)(implicit ec: ExecutionContext): Future[T] = {
val p = Promise[T]()
val task = timer.schedule(new Runnable {
def run(): Unit = { p.tryFailure(new TimeoutException(s"no result after $d")); () }
}, d.toNanos, TimeUnit.NANOSECONDS)
f.onComplete { r => task.cancel(false); p.tryComplete(r) }
p.future
}
}Like Await, this does not stop the slow work. It only stops your caller from waiting for it. If the work holds a connection or a lock, pass a cancellation signal into it, or use an effect system such as Cats Effect or ZIO fibers, where a timeout really interrupts the computation.
Virtual threads change the cost, not the rule
On JDK 21 and later you can run futures on ExecutionContext.fromExecutorService(Executors.newVirtualThreadPerTaskExecutor()). Await's latch parks the thread through the JDK's lock support, and a parked virtual thread releases its carrier thread, so a blocked wait costs a little heap rather than a whole platform thread. That removes most of the starvation risk described above. The pool has no fixed worker count for the waiting task to exhaust.
Two caveats remain. Before JDK 24, a virtual thread that blocks while holding a synchronized monitor pins its carrier thread, so blocking inside synchronized code still starves carriers on older JDKs. And virtual threads do not make a timeout cancel anything. A thread-per-task executor also has no limit on concurrency, so put a semaphore or bounded queue in front of scarce resources such as database connections.
Failure modes at a glance
| Symptom | Likely cause | Fix |
|---|---|---|
| Requests time out together under load, CPU idle | Await inside a pool that the awaited work needs | compose with flatMap; move blocking I/O to a dedicated pool |
| Thread count climbs into the hundreds | global pool compensating for blocking calls | remove Await from hot paths; bound blocking work separately |
| Test hangs with no output | Duration.Inf on a future that never completes (lost callback, fatal error) | finite timeouts in tests; check the uncaught-exception log |
| ExecutionException where you expected your error | the future failed with an Error such as NotImplementedError | unwrap getCause; replace ??? stubs |
| Work continues after a TimeoutException | a timeout ends the wait, not the computation | pass cancellation in, or use an effect system |
| IllegalArgumentException from Await | Duration.Undefined passed as atMost | use a finite duration or Duration.Inf |
What to do next
- Search the codebase for
Await.and classify each call site: main, test, sync bridge, shutdown, or something else. Treat each "something else" as a bug to remove. - Replace every
Duration.Infoutside tests with a finite timeout derived from a request or job deadline. - Run the two-thread deadlock program above against your own pool configuration, so the team has seen the failure once.
- Check every
catcharound Await forExecutionExceptionunwrapping and for interrupt handling. - Where service code uses Await only to impose a timeout, switch to a Promise-and-scheduler timeout or your effect system's
timeoutoperator. - If you run on JDK 21 or later, try a virtual-thread executor for blocking-heavy edges, and keep concurrency limits on scarce resources.