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.

Advertisement

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.

What one call to Await.result does on the calling threadAwait.result(f, d)caller threadAlready completed?fast path: no blockingReturn valueor throw failureblocking { ... }asks the BlockContextGlobal pool workermanagedBlock: may add a threadAny other threadno compensationLatch: park until doneor timeout elapsesTimeoutExceptionthe future keeps runningyesnocompleteddeadlineA fixed-size pool gets no extra thread,so every waiting task removes one worker.
Await returns at once for completed futures. Otherwise the global pool can compensate for the parked thread, and every other executor simply loses a worker until the future completes or the deadline passes.
Advertisement

Durations: Inf, zero, negative and Undefined

The atMost argument is a scala.concurrent.duration.Duration, and four kinds of value behave differently.

atMostFuture already completeFuture still running
Finite and positive, e.g. 5.secondsreturns at onceparks for up to 5 s, then TimeoutException
Duration.Zero or negativereturns at onceTimeoutException immediately, without parking
Duration.Infreturns at onceparks until completion or interrupt
Duration.UndefinedIllegalArgumentExceptionIllegalArgumentException

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 once

Avoid 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 main with a generous finite timeout and maps each outcome to an exit code.
  • Tests. Waiting keeps assertions simple. ScalaTest's ScalaFutures trait provides futureValue and whenReady with 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

SymptomLikely causeFix
Requests time out together under load, CPU idleAwait inside a pool that the awaited work needscompose with flatMap; move blocking I/O to a dedicated pool
Thread count climbs into the hundredsglobal pool compensating for blocking callsremove Await from hot paths; bound blocking work separately
Test hangs with no outputDuration.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 errorthe future failed with an Error such as NotImplementedErrorunwrap getCause; replace ??? stubs
Work continues after a TimeoutExceptiona timeout ends the wait, not the computationpass cancellation in, or use an effect system
IllegalArgumentException from AwaitDuration.Undefined passed as atMostuse a finite duration or Duration.Inf

What to do next

  1. 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.
  2. Replace every Duration.Inf outside tests with a finite timeout derived from a request or job deadline.
  3. Run the two-thread deadlock program above against your own pool configuration, so the team has seen the failure once.
  4. Check every catch around Await for ExecutionException unwrapping and for interrupt handling.
  5. Where service code uses Await only to impose a timeout, switch to a Promise-and-scheduler timeout or your effect system's timeout operator.
  6. If you run on JDK 21 or later, try a virtual-thread executor for blocking-heavy edges, and keep concurrency limits on scarce resources.
Key takeaway: Await is the deliberate, searchable way to turn a Scala Future into a value. result returns the value or throws the failure, ready returns the completed future, and both return immediately for futures that are already complete. A timeout ends the wait, never the work. Errors such as NotImplementedError arrive wrapped in ExecutionException, and fatal errors can leave a future that never completes. Blocking is safe only on a thread that the awaited work cannot need. The global pool hides the problem with up to 256 extra threads, and every other executor starves. Keep Await in main, tests, synchronous bridges and shutdown, with finite deadlines, and compose futures everywhere else.