Scala
Futures
Asynchronous Programming
Performance Optimization
Concurrency

Why aren't my scala futures more efficient?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Scala Future makes asynchronous code easier to write, but it does not automatically make code faster. A program can use futures heavily and still run slowly because the real bottleneck is blocking I/O, the wrong execution context, too many tiny tasks, or contention on shared resources. The practical question is not "am I using futures" but "what work is actually running, where, and with how much coordination overhead."

A Future Is Not Free Parallelism

A Future represents a computation that may complete later. That does not mean the computation becomes cheaper or that more threads automatically help.

Simple example:

scala
1import scala.concurrent.{ExecutionContext, Future}
2import scala.concurrent.ExecutionContext.Implicits.global
3
4val a = Future { Thread.sleep(1000); 1 }
5val b = Future { Thread.sleep(1000); 2 }
6
7val combined = for {
8  x <- a
9  y <- b
10} yield x + y

This code is asynchronous, but it still spends two seconds of blocked thread time in total. If enough futures block like that, the thread pool stops being productive.

The first mental model to keep is that Future improves coordination of asynchronous work. It does not eliminate the cost of the work itself.

Blocking Work Kills Throughput

The default global execution context is optimized for CPU-bound tasks, not arbitrary blocking I/O. If you call Thread.sleep, blocking database drivers, or slow network APIs inside futures, you can starve the pool.

Bad pattern:

scala
1import scala.concurrent.Future
2import scala.concurrent.ExecutionContext.Implicits.global
3
4def loadUser(id: Int): Future[String] =
5  Future {
6    Thread.sleep(500)
7    s"user-$id"
8  }

Better pattern is to use a dedicated pool for blocking work:

scala
1import java.util.concurrent.Executors
2import scala.concurrent.{ExecutionContext, Future}
3
4val blockingEc = ExecutionContext.fromExecutor(Executors.newFixedThreadPool(16))
5
6def loadUser(id: Int): Future[String] =
7  Future {
8    Thread.sleep(500)
9    s"user-$id"
10  }(blockingEc)

If the underlying library offers non-blocking APIs, use those instead of wrapping blocking calls in a future and hoping the scheduler makes it efficient.

Too Many Tiny Futures Add Overhead

Developers sometimes split trivial work into thousands of futures. That can be slower than running a simple loop because scheduling, queueing, and synchronization costs dominate the actual computation.

For CPU-bound work, batch tasks at a meaningful grain size:

scala
1import scala.concurrent.{Await, ExecutionContext, Future}
2import scala.concurrent.duration._
3import scala.concurrent.ExecutionContext.Implicits.global
4
5val chunks = List(
6  (1 to 250000).toVector,
7  (250001 to 500000).toVector,
8  (500001 to 750000).toVector,
9  (750001 to 1000000).toVector
10)
11
12val futures = chunks.map(chunk => Future(chunk.map(_ * 2).sum))
13val total = Await.result(Future.sequence(futures).map(_.sum), 10.seconds)
14
15println(total)

That is usually better than spawning one future per integer.

Composition Style Matters

Sequential dependency chains are often mistaken for parallel execution. Consider:

scala
1val result = for {
2  a <- fetchA()
3  b <- fetchB(a)
4} yield b

If fetchB depends on a, this is necessarily sequential. But if two futures are independent, start them before the for comprehension:

scala
1val fa = fetchA()
2val fb = fetchB()
3
4val result = for {
5  a <- fa
6  b <- fb
7} yield (a, b)

That is a common source of lost concurrency. The code may look asynchronous in both cases, but only the second version actually overlaps the independent work.

Shared Bottlenecks Still Dominate

Even perfectly composed futures cannot beat a shared bottleneck such as:

  • one saturated database connection pool
  • a rate-limited external API
  • synchronized locks around mutable state
  • a single disk or network bottleneck

If ten futures all wait on the same narrow resource, you do not get ten times the throughput. You only get more queued work and more scheduler overhead.

This is why performance analysis needs metrics beyond "number of futures created." Measure queue times, downstream latency, pool saturation, and blocking frequency.

Common Pitfalls

  • Running blocking I/O on the default global execution context.
  • Creating huge numbers of tiny futures for work that is too small to justify scheduling overhead.
  • Writing sequential future chains and assuming they are parallel.
  • Ignoring real downstream bottlenecks such as connection pools or synchronized state.
  • Using futures as a performance strategy without measuring where time is actually spent.

Summary

  • Futures improve coordination, not the intrinsic cost of the work being done.
  • Blocking tasks should not run on the same pool as CPU-bound future work.
  • Task granularity matters because many tiny futures can be slower than a plain loop.
  • Independent futures must be started independently if you want actual overlap.
  • Measure downstream bottlenecks before concluding that futures themselves are inefficient.

Course illustration
Course illustration

All Rights Reserved.