skip to content

What are the design limitations of java.util.concurrent.Future, and how do CompletableFuture and structured concurrency address them?

level: principalimportance: nice to knowfreq 40%

answer

  1. Future: only block (get) or poll (isDone) — no callbacks/compose/combine
  2. Errors are pull-based: surface only on get()
  3. CompletableFuture = Future + CompletionStage: thenApply/Compose/Combine, allOf/anyOf, exceptionally
  4. Gap: no shared lifetime/cancellation for a group
  5. StructuredTaskScope (21+): scoped forks, sibling cancel, join as a unit; pairs with virtual threads

basics

~20 s

Plain Future only lets you block on get() or poll isDone() — you can't attach a callback, chain steps, or combine several Futures without blocking a thread. CompletableFuture adds non-blocking composition and callbacks; structured concurrency manages a whole group of tasks with shared cancellation and scope.

solid answer

~50 s

Future is intentionally minimal: you can submit, then either block on get() or busy-poll isDone(). It offers no way to register a completion callback, no way to chain 'when this finishes, do that' without a blocking thread, no built-in combination of multiple Futures (waiting for all/any), and exceptions only surface lazily when you call get(). That forces blocking, thread-per-wait designs. CompletableFuture (Java 8) implements Future plus CompletionStage: thenApply/thenCompose/thenCombine chain transformations, allOf/anyOf combine many, whenComplete/exceptionally handle errors — all non-blocking and composable, completing the pipeline without parking threads. Its gap is lifecycle: a group of related CompletableFutures has no shared scope or cancellation. Structured concurrency (StructuredTaskScope, Java 21+ preview) closes that: tasks forked in a scope share a lifetime, errors/cancellation propagate to siblings, and the scope joins them as a unit — making concurrent code read like sequential code with reliable cleanup.

go deeper

for a junior

Aware that Future only lets you block on get() and that newer APIs (CompletableFuture) can do more.

for a middle

Lists Future's gaps (no callback/compose/combine) and names CompletableFuture's thenApply/thenCompose/allOf as the fixes.

for a senior

Explains push- vs pull-based completion, why thread-per-wait scales poorly, and maps CompletableFuture operators to composition/combination/error-handling needs.

for a principal

Compares all three models for an architecture, factoring virtual threads and structured concurrency's scoped cancellation/lifetime, and chooses per use case with trade-offs (allocation, readability, cancellation guarantees).

## What plain Future gives you — and what it doesn't `Future<V>` (Java 5) is deliberately small. Its only operations are: - `get()` / `get(timeout, unit)` — **block** waiting for the result. - `isDone()` / `isCancelled()` — **poll** state. - `cancel(mayInterruptIfRunning)`. From this small surface flow several **limitations**: 1. **No callbacks.** You cannot say 'run this code *when* the result is ready'. Your only options are to block a thread on `get()` or to repeatedly poll `isDone()` (busy-waiting). Both waste a thread per pending result. 2. **No composition/chaining.** There's no 'take this Future's result, transform it, producing another Future' without blocking to extract the value first. 3. **No combination.** Waiting for *all* of several Futures, or the *first* of them, must be hand-coded (e.g. a loop of `get()`s, or a `CompletionService`). 4. **Lazy, pull-based error delivery.** A task's exception sits dormant until *someone* calls `get()`; if nobody does, the failure can be silently lost. 5. **Encourages thread-per-blocking-wait designs**, which scale poorly: a server handling many concurrent operations ties up a pool thread for each outstanding `get()`. ## CompletableFuture (Java 8) — push-based composition `CompletableFuture<V>` implements `Future<V>` **and** `CompletionStage<V>`, adding a fluent, non-blocking API: ```java CompletableFuture .supplyAsync(() -> fetchUser(id), pool) // async produce .thenApply(User::email) // transform result .thenCompose(email -> lookupAsync(email)) // chain another async stage .thenCombine(otherFuture, (a, b) -> merge(a,b)) // combine two .exceptionally(ex -> fallback(ex)) // handle failure inline .whenComplete((res, ex) -> log(res, ex)); // callback on completion ``` Key additions: - **Callbacks** (`thenApply`, `thenAccept`, `whenComplete`) run *when* the stage completes — no blocking. - **Chaining** (`thenCompose` for dependent async steps). - **Combination** (`thenCombine` for two; `allOf`/`anyOf` for many). - **Inline error handling** (`exceptionally`, `handle`) — errors propagate **down the chain** automatically, push-based, rather than waiting for a `get()`. - **Manual completion** (`complete`, `completeExceptionally`) — you can fulfil it from any event source, decoupling it from executors. This turns 'block and wait' into 'declare a pipeline'; threads aren't parked just to wait. **What CompletableFuture still lacks:** a notion of a **group of related tasks with a shared lifetime**. If you fan out 10 CompletableFutures and one fails, the others keep running; there's no automatic sibling cancellation or scoped join, and leaked/forgotten stages are easy. ## Structured concurrency (StructuredTaskScope, Java 21+, preview) Structured concurrency applies the principle that **concurrent subtasks should have a lifetime bounded by a lexical scope**, just like a block bounds local variables. `StructuredTaskScope` lets you fork subtasks and then `join()` them as a unit: ```java try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Subtask<User> user = scope.fork(() -> fetchUser(id)); Subtask<Order> order = scope.fork(() -> fetchOrder(id)); scope.join(); // wait for all forks scope.throwIfFailed(); // propagate the first failure return combine(user.get(), order.get()); } // scope closes -> any still-running forks are cancelled ``` What it adds over CompletableFuture: - **Shared lifetime & scope:** all forks live and die within the try-with-resources block; nothing leaks past it. - **Error/cancellation propagation:** policies like `ShutdownOnFailure` cancel siblings as soon as one fails; `ShutdownOnSuccess` (first-good-answer) cancels the rest once one succeeds. - **Readability:** concurrent code reads top-to-bottom like sequential code, with reliable cleanup and clear ownership. - **Pairs naturally with virtual threads (Java 21)**, where blocking a (cheap) virtual thread is fine — so the 'thread-per-wait' cost that motivated CompletableFuture's non-blocking style is largely removed, and simple blocking code becomes attractive again. ## How to choose (architect's view) - **Plain Future / CompletionService:** a simple batch of independent tasks where you just need the results, possibly in completion order. Lightweight, allocation-cheap. - **CompletableFuture:** asynchronous *pipelines* — dependent transformations, fan-in/fan-out combination, callback-driven flows, integration with event sources. Best when you must avoid blocking platform threads. - **Structured concurrency + virtual threads:** when you want **grouped** subtasks with reliable joint cancellation, propagation, and sequential-looking code, and can target Java 21+. The through-line: `Future` solved *'get a value later'*; `CompletableFuture` solved *'compose async steps without blocking'*; structured concurrency solves *'manage a family of concurrent tasks as one unit with safe lifetimes'*.

  • Why does plain Future encourage thread-per-blocking-wait designs, and why is that a scaling problem?
    Because the only way to consume a result is to block a thread on get() (or busy-poll). Each pending result thus ties up a platform thread; with many concurrent operations you exhaust the pool. CompletableFuture (non-blocking callbacks) or virtual threads (cheap to block) both relieve this.
  • What does StructuredTaskScope add that a set of independent CompletableFutures does not?
    A shared, lexically-bounded lifetime: forked subtasks are joined as a unit and, via policies like ShutdownOnFailure/ShutdownOnSuccess, a failure or first success automatically cancels the siblings — with guaranteed cleanup when the scope closes. Independent CompletableFutures have no such shared scope or joint cancellation.

saying these in an interview costs you the question

  • Claiming plain Future supports callbacks or chaining (it doesn't — that's CompletableFuture).
  • Saying CompletableFuture's get() is the normal way to consume it — the point is non-blocking callbacks.
  • Thinking CompletableFuture gives grouped cancellation of related tasks.
  • Confusing structured concurrency with just 'using a thread pool'.
  • Assuming virtual threads make CompletableFuture obsolete in all cases — pipelines/combination still benefit.

context