Skip to content
← All insights
Scala concurrency15 min read

Using Futures in Scala application code and tests

How Scala Future starts, composes and fails; how ExecutionContext choices affect Play services; and how to test asynchronous behaviour without sleeps or misleading mocks.

A Future represents one running or completed result

Scala Future[A] represents a value that may become available later or fail with a Throwable. Creating a Future with Future { ... } submits its body to an ExecutionContext immediately. It is eager, unlike an effect value such as Cats Effect IO that describes work for later interpretation.

A Future memoises its completion. Several callbacks attached to the same instance observe the same success or failure; they do not rerun its body. Calling a method that creates a new Future several times is different because each call creates and schedules new work.

Keep construction visible when timing matters. A val holding an existing Future and a def returning a fresh Future have similar types at the call site but different execution behaviour.

One Future instance has one completionscala 2.13
def fresh(): Future[UUID] = Future(UUID.randomUUID())

val shared: Future[UUID] = fresh()
val first  = shared.map(identity)
val second = shared.map(identity) // observes the same completion

val another: Future[UUID] = fresh() // schedules new work

map and flatMap sequence successful completion

map transforms a successful result and propagates an existing failure. flatMap is required when the next function returns another Future. If a callback throws, the Future returned by map or flatMap completes as a failure.

A for-comprehension translates to flatMap and map. Creating every independent Future before the comprehension lets the operations start independently; creating the second inside a generator makes it wait for the first.

Parallelism is not automatically faster or safer. Start work independently only when there is no data dependency, the downstream system can accept the concurrency and failure of one operation does not require preventing the other from starting.

Construction position controls when work can startscala 2.13
val accountF: Future[Account] = accounts.find(accountId)
val scoreF: Future[Score] = scores.find(accountId)

val assessmentF: Future[Assessment] = for {
  account <- accountF
  score   <- scoreF
} yield Assessment(account, score)

// Dependent: the second call starts only after account arrives.
val historyF = accounts.find(accountId).flatMap { account =>
  history.forAccount(account.id)
}

The ExecutionContext is an operational dependency

Every transformation needs an ExecutionContext on which its callback can run. In a Play application, the default context is intended for asynchronous, non-blocking application work. Blocking database, file or network calls can occupy those threads and reduce request throughput.

Moving a blocking call into Future does not make the call non-blocking; it moves the wait to a thread. Isolate unavoidable blocking work on an appropriately configured execution context and keep the ownership of that context clear in application wiring.

Do not create a new thread pool per request or service instance. Pool size, queueing and shutdown are application concerns. Monitor saturation and latency rather than assuming the presence of Future means concurrency is healthy.

Isolate an unavoidable blocking adapterscala 2.13 / Play
import scala.concurrent.{blocking, ExecutionContext, Future}

final class LegacyAccountRepository(
  client: BlockingAccountClient,
  blockingEc: ExecutionContext
) {
  def find(id: AccountId): Future[Option[Account]] =
    Future {
      blocking(client.find(id))
    }(blockingEc)
}

// The controller and service need not know how the adapter blocks.

Keep expected outcomes distinct from failed Futures

A failed Future means no normal result was produced: an unexpected exception escaped, a database was unavailable or another operational failure occurred. A value such as Future[Either[RenameError, Account]] can distinguish that channel from a known application rejection.

recover transforms selected failures into successful values; recoverWith starts another asynchronous recovery. Catch only failures the current boundary can interpret. Recovering every Throwable into an empty result can turn an outage or defect into false business data.

Future has no general cancellation or resource-lifetime model. A request timing out does not necessarily stop the underlying work. APIs that acquire resources must own their release independently of whether a caller continues waiting.

Recover only the failure this boundary ownsscala 2.13
def find(id: AccountId): Future[Either[FindError, Account]] =
  repository.find(id).map {
    case Some(account) => Right(account)
    case None          => Left(FindError.NotFound(id))
  }.recover {
    case error: InvalidStoredAccount =>
      Left(FindError.InvalidData(error.field))
  }

// Connection failures still fail the Future for the outer boundary to handle.

Test completion instead of waiting for time to pass

Never use Thread.sleep to give a Future time to finish. It makes tests slow and timing-dependent. ScalaTest provides ScalaFutures and asynchronous suite styles that complete the test from the Future itself.

futureValue is convenient for focused tests when patience settings are sensible. When testing a failure, inspect failed.futureValue or recoverToExceptionIf rather than wrapping Await.result in an intercept block. Await is best kept at process boundaries and out of ordinary production composition.

Use Future.successful and Future.failed for dependencies whose answer is already known. Constructing Future { value } in a stub schedules work that the test does not need and makes execution depend unnecessarily on a thread pool.

Assert asynchronous success and failure directlyscala 2.13 / ScalaTest
class AccountServiceSpec
    extends AsyncWordSpec
    with Matchers {

  "rename" should {
    "return the updated account" in {
      val repository = new StubRepository(Some(account))

      service(repository).rename(account.id, newName).map { result =>
        result shouldBe Right(expected)
      }
    }

    "retain repository failure" in {
      val failure = new RuntimeException("database unavailable")
      val repository = StubRepository.failed(failure)

      recoverToExceptionIf[RuntimeException] {
        service(repository).rename(account.id, newName)
      }.map(_ shouldBe failure)
    }
  }
}

Control promises only when the timing is under test

Promise[A] gives a test explicit control over when a Future completes. It is useful for proving that a second operation does not begin before a prerequisite, or that independently created work is not accidentally serialised.

A Promise-heavy test can be as coupled as a mock-heavy one. Use it for a concurrency property visible in the service contract, not to reproduce every callback. Complete every Promise or ensure the test can terminate on failure.

ExecutionContext.parasitic can execute short callbacks on the completing thread, but using it broadly in tests can hide scheduling assumptions that differ in production. Prefer the same execution model as the application unless the test is deliberately isolated to pure, immediate transformations.

Control one meaningful completion boundaryscala 2.13 / ScalaTest
val accountResult = Promise[Account]()
val repository = new StubRepository(accountResult.future)

val result = service(repository).loadAndScore(accountId)
scoring.requests shouldBe empty

accountResult.success(account)

whenReady(result) { assessment =>
  assessment.account shouldBe account
}
scoring.requests should contain(account.id)

Test the adapters Future cannot abstract away

A unit test can prove how a service composes successful and failed Futures. It cannot prove that the real database client is genuinely asynchronous, that the configured pool is large enough or that a Play action serialises failures correctly.

Add integration tests for the real repository and HTTP boundary, and observe thread pools, latency and failures under a representative workload. Production support needs logs with useful context at the boundary that finally handles a failed Future—not the same exception logged in every map and flatMap.

Review Future code by tracing construction, dependency and failure. Ask when the work starts, which execution context runs it, whether operations are independent, what a failed Future means and how the test knows completion occurred.

  • Is blocking work isolated from Play’s default execution context?
  • Are independent Futures created before they are combined?
  • Do typed application rejections remain values rather than failed Futures?
  • Does recovery preserve unexpected operational failures?
  • Do tests wait on completion rather than elapsed time?
  • Are real adapters and failure paths covered beyond mocked unit tests?