Core concept

A Promise[T] is a write-once holder for a value or exception. Unlike a Future[T], which is read-only, a Promise is the producer side — you complete it exactly once with either success or failure, and every attached Future sees that completion.

Creation is straightforward. Promise[Int]() creates an empty promise. You then use .success(value), .failure(exception), or .complete(Try[T]) to set the value. Once set, further completion attempts are ignored. Any caller reading .future sees an immutable read-only handle that resolves when the promise completes.

val p = Promise[Int]()
p.success(42)                          // Completes the promise
p.future.foreach(println)              // Prints: 42

// Can't complete twice; second call is ignored
p.success(99)                          // Ignored
p.future.value                         // Still Success(42)

// You can also complete with a failure
val p2 = Promise[String]()
p2.failure(new Exception("oops"))
p2.future.recover { case e => "error" }
Advertisement

How it works

A Promise holds one of three states: pending, completed with success, or completed with failure. The state is set atomically the first time you call any completion method. Subsequent completion attempts check the state, see it's already set, and silently discard the new value.

Under the hood, the Promise wraps an AtomicReference or similar lock-free primitive. All registered callbacks (via .map, .flatMap, .onComplete) get notified when the promise completes. The notifications happen on the ExecutionContext that was current when the callback was registered, not when the promise was created.

Success and failure are symmetric: .success(v) is equivalent to .complete(Success(v)), and .failure(e) is .complete(Failure(e)). If you have a Try already, use .complete directly.

// Idempotent completion
val p = Promise[Int]()
val isFirst = p.trySuccess(10)     // Returns true
val isSecond = p.trySuccess(20)    // Returns false (already set)

p.future.value                      // Some(Success(10))

// Using complete() with Try
import scala.util.Try
val p2 = Promise[String]()
val result: Try[String] = Try { "hello".length.toString }
p2.complete(result)

// Composing with futures
val f = Future { 42 }
val p3 = Promise[Int]()
f.onComplete(p3.complete)          // Forward completion to promise
p3.future.value                     // Gets the same value as f

This completion-forwarding pattern is why Promises exist: to bridge async sources (callbacks, channels, messages) that don't produce Futures natively. The Promise becomes the meeting point where external events turn into Future values.

Advertisement

Bridging callbacks to Futures

The classic use case: you have a callback-based API (e.g., a database driver or HTTP client that takes a callback) and you want a Future to compose with other async code. Create a Promise, pass a callback that completes it, return the Future.

// Old callback-based API
class CallbackDB {
  def query(sql: String, callback: String => Unit): Unit = {
    // ... runs in thread pool, eventually calls callback with result
  }
}

// Bridge to Future
def queryFuture(db: CallbackDB, sql: String): Future[String] = {
  val p = Promise[String]()
  db.query(sql, { result =>
    p.success(result)
  })
  p.future
}

// Now you can compose
for {
  users <- queryFuture(db, "SELECT * FROM users")
  count <- queryFuture(db, s"SELECT COUNT(*) FROM ($users)")
} yield count

This pattern is so common that Scala has a helper: Promise.fromTry and Promise.successful/failed for pre-fulfilled promises, and some libraries provide their own sugar. But the core pattern is always: create Promise, attach callback, return .future.

Error handling with promises

Promises propagate failures just like Futures. If you call .failure(exception), any composed operations (.map, .flatMap, .recover) downstream will see the exception.

val p = Promise[Int]()
p.failure(new RuntimeException("DB unavailable"))

val result = p.future.recover { case e => -1 }
result.value                        // Some(Success(-1))

// Or with Try.fromEither or pattern matching in callbacks
val p2 = Promise[String]()
someCallbackAPI { (error, value) =>
  if (error != null) p2.failure(error)
  else p2.success(value)
}

// onFailure on the future
p2.future.onFailure {
  case e => logger.error("Failed", e)
}

The key: if the external source (callback API) gives you an error, wrap it in a Failure/exception and feed it to the promise. Callers downstream will handle it like any other Future error.

Concurrency and thread safety

Promise completion is atomic and thread-safe. You can complete a Promise from multiple threads; the first one wins, the rest are no-ops. No locks needed from the caller — the Promise handles it.

val p = Promise[Int]()

// Thread 1
new Thread(() => {
  Thread.sleep(10)
  p.success(1)                      // Wins
}).start()

// Thread 2
new Thread(() => {
  p.success(2)                      // Ignored, p already has value 1
}).start()

Thread.sleep(50)
p.future.value                      // Some(Success(1))

// Use trySuccess/tryFailure if you need to know whether completion happened
val p2 = Promise[String]()
if (p2.trySuccess("first")) println("Won")
else println("Lost")

If you need to coordinate multiple threads completing the same Promise, use trySuccess and tryFailure which return booleans indicating whether the completion took. Otherwise, the idempotent semantics mean it doesn't matter who calls .success first — it's deterministic and safe.

Trade-offs and common gotchas

Manual error handling: Unlike Futures created by Future { block }, which automatically catch exceptions in the block, a Promise requires you to manually call .failure() if the callback receives an error. Easy to forget.

Dangling promises: If you create a Promise and never complete it, any code waiting on the Future hangs forever. There's no timeout on a Promise itself; you must add one at the Future level if needed.

// Bad: if the callback never fires, p hangs forever
val p = Promise[String]()
externalAPI(callback = p.success)
// If externalAPI crashes without calling callback, p is stuck

// Better: wrap in a timeout
val p = Promise[String]()
val withTimeout = Promise.fromFuture(
  Future { // This completes in 5 seconds
    Thread.sleep(5000)
    p.future
  }(global)
)
externalAPI(callback = p.success)
// Now p itself times out if externalAPI never calls

ExecutionContext leaks: Each registered callback runs on an ExecutionContext (defaulting to the global one). If you register many callbacks on a Promise that never completes, they pile up in memory. Not common, but possible if Promises are held in long-lived data structures.

Use Promises when: bridging callback APIs to Futures, coordinating multiple async sources into one result, building custom async combinators. Avoid Promises when: you can directly return a Future from your async source, or when simpler constructs (channels, actors) better fit the problem.

Promises vs Futures vs other tools

A Future[T] represents a value that might exist later. You can't directly set it; it completes when the computation it wraps finishes. A Promise[T] is the producer side — you complete it by hand.

In competitive APIs:

Promise: Explicit completion by you. Good for bridging callbacks and coordinating multiple sources. Simple but manual.

scala.concurrent.Channel (Scala 2.13+): Multiple producers, multiple consumers, queue semantics. Better for fan-out scenarios.

Akka Futures (now part of Pekko): Actor-based, message-passing concurrency. Heavier weight but more feature-rich for complex patterns.

fs2 / ZIO Fibers: Functional effect systems. Promises are rarely needed; these systems have their own async primitives.

// Promise: one producer, bridge callback
val p = Promise[Int]()
callbackAPI(p.success)
val result = p.future

// Equivalent with scala.util.Try
import scala.util.Try
val t: Try[Int] = Try { callbackAPI() }  // If it's synchronous

// vs Akka/Pekko actors: message-driven
class Producer(target: ActorRef) extends Actor {
  def receive = {
    case Compute => target ! Result(42)
  }
}

// vs fs2
val result: fs2.Stream[IO, Int] = ???

Combining multiple Promises

You can coordinate multiple independent async sources by creating separate Promises for each and combining their Futures. Scala's Future.sequence and Future.traverse help combine the results.

// Collect results from multiple callback-based APIs
val p1 = Promise[String]()
val p2 = Promise[Int]()
val p3 = Promise[Boolean]()

callbackAPI1(p1.success)
callbackAPI2(p2.success)
callbackAPI3(p3.success)

// Combine all three
val combined = for {
  name <- p1.future
  count <- p2.future
  active <- p3.future
} yield (name, count, active)

// Or using Future.sequence
val promises = List(p1, p2.future.map(_.toString), p3.future.map(_.toString))
val allTogether = Future.sequence(promises)

// And map/flatMap work on promises just like futures
p1.future
  .map(_.toUpperCase)
  .flatMap(name => {
    val p = Promise[String]()
    callbackAPI4(p.success)
    p.future.map(data => s"$name: $data")
  })

This pattern scales: if you have a system with multiple async sources (WebSocket connections, database callbacks, message queues), create a Promise per source and compose their Futures to drive downstream logic.

Real-world example: wrapping a callback-based HTTP client

Many legacy libraries (and some modern ones) use callbacks instead of Futures. Here's how to wrap them cleanly with Promises:

import scala.concurrent.{Future, Promise}

class AsyncHTTPClient {
  // Old-style callback API
  def get(url: String, onSuccess: String => Unit, onError: Throwable => Unit): Unit = {
    // Makes an HTTP request, calls one of the callbacks
  }
}

// Wrap with Promise
class FutureHTTPClient(val client: AsyncHTTPClient) {
  def get(url: String): Future[String] = {
    val p = Promise[String]()

    client.get(
      url,
      onSuccess = { response =>
        p.success(response)
      },
      onError = { error =>
        p.failure(error)
      }
    )

    p.future
  }
}

// Now you can use it like any other Future
val wrapper = new FutureHTTPClient(client)

// Chain requests
val result = for {
  html <- wrapper.get("https://example.com")
  parsed <- Future { parseHTML(html) }
  links <- Future { extractLinks(parsed) }
} yield links

// Add timeout
val withTimeout = Future.firstCompletedOf(Seq(
  wrapper.get("https://slow.com"),
  Future {
    Thread.sleep(5000)
    throw new TimeoutException("took > 5s")
  }
))

This is a production pattern: Promise acts as the adapter layer between callback-based and Future-based APIs. Once wrapped, all the power of Scala's async combinators (map, flatMap, for-comprehensions, Future.traverse, etc.) becomes available.

Performance considerations

Promises are lightweight. Creation is O(1). Completion is O(1) on the happy path — just setting a field atomically. Notifying callbacks is O(n) where n is the number of registered callbacks, but that's unavoidable and typically n is small.

The ExecutionContext for callback execution matters: if you register many callbacks on a Promise and they all run on the same ExecutionContext thread pool, they'll contend for threads. Use .map and .flatMap sparingly if you're composing thousands of Promises. For very high concurrency, consider effect systems like ZIO or Cats Effect that have more tuned schedulers.

Memory: a Promise holds a reference to all registered callbacks until it completes. If you never complete the Promise and keep it alive, those callbacks are never garbage-collected. This is rare but possible if Promises are stored in long-lived data structures (e.g., a cache). Always ensure Promises complete eventually.

Debugging Promises

A common gotcha: a Promise never completes, and code waiting on it hangs silently. To debug:

Enable logging: Add a log line in the callback that completes the Promise. If it never appears, the callback never fired.

Use Await with timeout: In tests, use Await.ready(p.future, Duration(5, "seconds")) to see if it completes in a reasonable time.

import scala.concurrent.Await
import scala.concurrent.duration.Duration

val p = Promise[String]()

// In a test
try {
  val result = Await.result(p.future, Duration(2, "seconds"))
  println(s"Got: $result")
} catch {
  case e: java.util.concurrent.TimeoutException =>
    println("Promise never completed!")
}

// Or just check the state directly
p.future.value match {
  case Some(scala.util.Success(v)) => println(s"Completed with: $v")
  case Some(scala.util.Failure(e)) => println(s"Failed: $e")
  case None => println("Still pending")
}

In production code, avoid Await.result (it blocks the calling thread) and use callbacks or for-comprehensions instead. But for debugging, it's invaluable.

Key takeaways

Promise[T] is the write-half of a Future: create it, complete it once with .success/.failure/.complete, return .future for others to observe. Thread-safe, idempotent, perfect for bridging callback APIs. Combine multiple Promises with for-comprehensions. Add timeouts at the Future level. Don't use for everything — Futures, Channels, and effect systems have their place — but when you need to hand-complete an async result, Promise is the right tool.