Back to Blog
Java

Java Future vs CompletableFuture: When to Use Which

java future vs completablefuture: Compare Java Future and CompletableFuture: blocking behavior, composition, error handling, and when each API fits your async workflow.

JavaCompletableFutureFutureConcurrencyAsync ProgrammingExecutorService
Illustration comparing Java Future's blocking get() with CompletableFuture's non-blocking composition pipeline.

The choice between java future vs completablefuture usually comes down to one question: can your code afford to block while waiting for a result? Future.get() blocks the calling thread until the task completes, while CompletableFuture lets you attach callbacks and compose asynchronous operations without ever blocking a thread. That single difference drives most of the design decisions in real applications.

The Core Difference: Blocking Versus Composition

Future is a result handle: you submit work to an executor and later retrieve the outcome. The retrieval mechanism, get(), blocks the calling thread until the task finishes. That is acceptable when the caller has nothing else to do, but it becomes a bottleneck in code that handles many concurrent requests on a limited thread pool.

CompletableFuture keeps the same result-handle semantics but adds a dependency model. You can register stages that run when the computation completes, without occupying a thread while waiting. Instead of blocking, the caller returns immediately and the pipeline continues on the completion thread.

What java.util.concurrent.Future Actually Provides

Future represents the result of an asynchronous computation submitted to an ExecutorService. The API is intentionally small: get() retrieves the result, cancel() attempts to cancel the task, and isDone() and isCancelled() report state.

ExecutorService executor = Executors.newFixedThreadPool(4); Future<Integer> future = executor.submit(() -> computeValue()); int result = future.get(); // blocks until the task finishes

This works well when the calling thread has nothing else to do while the task runs. The executor handles the concurrency, and the caller retrieves the result when ready. The limitation appears when you need the result to drive further work: you either call get() and block, or you poll with isDone() in a loop, which wastes CPU cycles.

Future also cannot be completed manually. The only way to produce a result is to submit a Callable or Runnable to an executor. If you are integrating with a callback-based library, you have no way to bridge that callback into a Future.

How CompletableFuture Extends the Future Contract

CompletableFuture implements both Future and CompletionStage. It keeps the get(), cancel(), and isDone() methods, so it can be used anywhere a Future is expected. What it adds is the ability to complete the future explicitly and to attach dependent stages.

CompletableFuture<Integer> future = new CompletableFuture<>(); // Later, from another thread: future.complete(42);

The complete() method is what makes CompletableFuture useful for bridging callback-based APIs. When a third-party library invokes its callback, you call complete() or completeExceptionally() to release any dependent stages.

The static factory methods supplyAsync() and runAsync() start a computation on a thread pool and return a CompletableFuture immediately.

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> computeValue());

The calling thread continues executing without blocking. The dependent stages you attach later run when the computation finishes.

Chaining and Composition Without Blocking

The main reason to prefer CompletableFuture over Future is composition. You can attach a sequence of transformations that run as soon as the previous stage completes.

CompletableFuture.supplyAsync(() -> fetchOrder(orderId)) .thenApply(order -> applyDiscount(order)) .thenAccept(order -> persistOrder(order));

Each thenApply stage receives the result of the previous stage and returns a new value. thenAccept consumes the final value and produces no result. None of these calls block the calling thread; they register work that runs on the completion thread.

When two independent computations must run in parallel and their results combined, thenCombine handles the merge:

CompletableFuture<Integer> prices = CompletableFuture.supplyAsync(() -> fetchPrice("AAPL")); CompletableFuture<Integer> volumes = CompletableFuture.supplyAsync(() -> fetchVolume("AAPL")); prices.thenCombine(volumes, (price, volume) -> price * volume) .thenAccept(System.out::println);

For waiting on several futures, allOf() and anyOf() provide the same coordination that you would otherwise build manually with a CountDownLatch or repeated get() calls.

Error Handling: Where Future Falls Short

With Future, error handling is limited to catching exceptions around get(). The executor wraps any exception thrown by the task in an ExecutionException, so you need to unwrap it to find the real cause.

try { int result = future.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (ExecutionException e) { Throwable cause = e.getCause(); // handle the actual failure }

CompletableFuture treats exceptions as first-class values in the pipeline. The exceptionally() method supplies a fallback when a stage fails, and handle() receives both the result and the exception so it can react to either.

CompletableFuture.supplyAsync(() -> computeValue()) .exceptionally(ex -> fallbackValue()) .thenAccept(System.out::println);

If an exception propagates through a chain without being handled, the final stage completes exceptionally. Calling get() on it throws ExecutionException, but you can also attach a whenComplete() stage to observe the failure without blocking.

Performance and Operational Considerations

Future is a thin wrapper around an executor task. The overhead is minimal: one object per submitted task, and the executor handles scheduling.

CompletableFuture carries more state. Each stage tracks its result, dependencies, completion callbacks, and thread continuation details. Long chains create multiple intermediate objects. For a single asynchronous call this overhead is negligible, but for thousands of short-lived tasks per second, the difference in allocation and GC pressure is measurable.

The *Async variants of CompletableFuture methods run on the common ForkJoinPool by default. That pool is shared across the JVM and sized to the number of CPU cores. Long-running or blocking tasks submitted to it can starve other parts of the application. Pass an explicit Executor to supplyAsync() or to the *Async methods when your tasks are I/O-bound or have variable duration.

ExecutorService pool = Executors.newFixedThreadPool(8); CompletableFuture.supplyAsync(() -> callExternalService(), pool) .thenApplyAsync(result -> transform(result), pool);

Using a dedicated pool also keeps your async pipeline from competing with parallel streams and other framework tasks that rely on the common pool.

Choosing Between Future and CompletableFuture

Use Future when the calling thread has nothing else to do and blocking is acceptable. A simple executor.submit() followed by get() is clear, readable, and has the lowest overhead. This pattern fits batch processing, startup initialization, and any code that must wait for a result before proceeding.

Use CompletableFuture when the workflow has multiple dependent steps, when several tasks must run in parallel and their results combined, or when you want to avoid blocking threads in a server handling many concurrent requests. The composition methods eliminate the nested callback structure that would otherwise appear.

There is also a middle path. CompletableFuture extends Future, so you can return a CompletableFuture from a method whose signature declares Future. The caller can still call get() if it wants to block, while the implementation benefits from manual completion and composition. This keeps the API contract stable while giving the implementation room to evolve.

One practical constraint: CompletableFuture does not replace every use of Future. If you are working with an existing executor-based design and simply need to collect results, introducing CompletableFuture adds complexity without benefit. The decision should follow the shape of the workflow, not the availability of the newer API.

java future vs completablefuture: Practical Usage and Code E | RYUSLOG DEV