Futures & CompletableFuture

Future represents the eventual result of an asynchronous task, while CompletableFuture builds on that idea with a rich, composable API for chaining, combining, and handling errors across asynchronous pipelines without blocking.

When a task is submitted to an ExecutorService, the caller usually needs a way to eventually retrieve its result or find out it failed — that handle is a Future. The original Future interface (Java 5) is deliberately minimal: get a result, check status, or cancel. CompletableFuture (Java 8) implements Future but adds a large, fluent API for composing asynchronous steps together — chaining transformations, combining independent futures, and handling errors — all without the caller having to block and wait at every step.

Future: A Handle to an Eventual Result

java.util.concurrent.Future<V> represents the result of an asynchronous computation that may not have completed yet. It is returned by ExecutorService.submit(Callable<V>) (and the Runnable overload, as Future<?>). Its core methods: V get() (blocks until done, returns the result or throws), V get(long timeout, TimeUnit unit) (blocks with a bound), boolean isDone(), boolean isCancelled(), and boolean cancel(boolean mayInterruptIfRunning).

get() is a blocking call — the calling thread does useful work in between submit() and get(), but get() itself waits until the task actually finishes.

import java.util.concurrent.*;

ExecutorService pool = Executors.newSingleThreadExecutor();
Future<Integer> future = pool.submit(() -> {
    Thread.sleep(1000);
    return 42;
});

System.out.println("submitted, doing other work...");
int result = future.get(); // blocks here until the task finishes
System.out.println("result: " + result);
pool.shutdown();

get() and Exception Handling

If the task threw an exception, get() throws ExecutionException wrapping the original cause (retrievable via getCause()) — never the raw original exception directly, since Future.get()'s signature can't declare every possible task-specific checked exception. get() itself can also throw InterruptedException if the waiting thread is interrupted, and CancellationException if the task was cancelled before it completed.

get()'s checked-exception signature (throws InterruptedException, ExecutionException) must always be handled or declared — a frequent source of boilerplate that CompletableFuture significantly improves on.

Future<Integer> future = pool.submit(() -> { throw new IllegalStateException("bad state"); });
try {
    future.get();
} catch (ExecutionException e) {
    System.out.println("task failed: " + e.getCause()); // IllegalStateException: bad state
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

Timeouts

get(long timeout, TimeUnit unit) blocks only up to the given duration, throwing TimeoutException if the task hasn't completed by then (the task itself keeps running unless separately cancelled).

A timeout does not cancel the underlying task — it only bounds how long the calling thread is willing to wait for it.

try {
    int result = future.get(500, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
    System.out.println("still not done after 500ms");
}

Cancellation

cancel(boolean mayInterruptIfRunning) attempts to cancel the task. If it hasn't started yet, cancellation typically succeeds and it never runs. If it's already running, mayInterruptIfRunning=true attempts to interrupt the running thread (which only has an effect if the task's code actually checks for/responds to interruption), while false leaves it running to completion but marks the Future as cancelled for reporting purposes. Once cancelled, isCancelled() returns true and get() throws CancellationException.

The Limits of Plain Future

Plain Future has no way to attach a callback for when it completes (you can only block on get()), no way to combine two Futures' results together, and no way to chain 'when this finishes, then do this next step' without manually blocking in between — which defeats much of the purpose of asynchrony. This is exactly the gap CompletableFuture closes.

CompletableFuture: Creating Async Pipelines

java.util.concurrent.CompletableFuture<T> implements both Future<T> and CompletionStage<T>. It can be created already completed (completedFuture(value)), or as the result of asynchronous work via the static factory methods supplyAsync(Supplier<T>) (returns a value) and runAsync(Runnable) (no return value), each optionally taking an Executor to control which thread pool runs the work (defaulting to the common ForkJoinPool otherwise).

supplyAsync() starts the computation immediately on a background thread and returns right away with a CompletableFuture handle to its eventual result.

import java.util.concurrent.CompletableFuture;

CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> {
    // runs asynchronously, e.g. on the common ForkJoinPool
    return 10 * 4;
});
System.out.println(cf.get()); // 40 (get() still blocks, inherited from Future)

thenApply: Transforming a Result

thenApply(Function<T,R>) registers a transformation to run once the CompletableFuture completes, producing a new CompletableFuture<R> — without blocking the calling thread to wait for the first stage. This is analogous to Stream.map() but for an asynchronous value instead of a collection element.

Each thenApply() call chains a synchronous transformation onto the previous stage's result; the whole chain still runs asynchronously relative to the calling thread until get() is called.

CompletableFuture<String> pipeline = CompletableFuture
    .supplyAsync(() -> 21)
    .thenApply(n -> n * 2)         // 42
    .thenApply(n -> "Result: " + n); // "Result: 42"

System.out.println(pipeline.get()); // "Result: 42"

thenCompose: Chaining Dependent Async Operations

thenCompose(Function<T, CompletionStage<R>>) is used when the next step is itself another asynchronous operation returning a CompletableFuture, avoiding a nested CompletableFuture<CompletableFuture<R>> that thenApply would produce. This is the async equivalent of Optional.flatMap() / Stream.flatMap().

If thenApply had been used instead of thenCompose here, the result type would be the awkward, doubly-wrapped CompletableFuture<CompletableFuture<Order>> — thenCompose 'flattens' the nesting.

CompletableFuture<User> userFuture = fetchUserAsync(userId);
CompletableFuture<Order> orderFuture = userFuture.thenCompose(user -> fetchLatestOrderAsync(user.id()));
// orderFuture is CompletableFuture<Order>, not CompletableFuture<CompletableFuture<Order>>

thenCombine: Merging Two Independent Futures

thenCombine(CompletionStage<U>, BiFunction<T,U,R>) waits for both this future and another, independent one to complete, then combines their two results with the given function — useful when two async operations don't depend on each other and can run concurrently, but their results are both needed together.

priceFuture and taxRateFuture run concurrently (independently), and thenCombine only proceeds once both have completed, combining their results without either one blocking on the other during execution.

CompletableFuture<Integer> priceFuture = CompletableFuture.supplyAsync(() -> 100);
CompletableFuture<Double> taxRateFuture = CompletableFuture.supplyAsync(() -> 0.08);

CompletableFuture<Double> totalFuture = priceFuture.thenCombine(
    taxRateFuture,
    (price, taxRate) -> price * (1 + taxRate)
);

System.out.println(totalFuture.get()); // 108.0

exceptionally and handle: Error Handling

exceptionally(Function<Throwable,T>) supplies a fallback value if any earlier stage in the chain completed exceptionally, and is skipped entirely if the chain succeeded. handle(BiFunction<T,Throwable,R>) runs regardless of success or failure, receiving either the result (with a null throwable) or the throwable (with a null result), letting one callback handle both outcomes.

exceptionally() only fires on failure and must produce a value of the same type; handle() always fires and receives both slots, exactly one of which is non-null.

CompletableFuture<Integer> withFallback = CompletableFuture
    .supplyAsync(() -> { throw new RuntimeException("failed"); })
    .exceptionally(ex -> {
        System.out.println("recovered from: " + ex.getMessage());
        return -1; // fallback value
    });
System.out.println(withFallback.get()); // -1

CompletableFuture<String> handled = CompletableFuture
    .supplyAsync(() -> 10 / 0)
    .handle((result, ex) -> ex != null ? "error: " + ex.getMessage() : "value: " + result);
System.out.println(handled.get()); // "error: / by zero"

Combining Many Futures: allOf and anyOf

CompletableFuture.allOf(cf1, cf2, ...) returns a CompletableFuture<Void> that completes once every given future completes (individual results must still be retrieved from the originals via get(), since allOf itself discards them). CompletableFuture.anyOf(cf1, cf2, ...) completes as soon as any one of the given futures completes, with that one's result (typed as Object).

allOf is commonly used to wait for a batch of independent async operations to all finish before proceeding, then each individual result is pulled from its own future.

CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> 1);
CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> 2);
CompletableFuture<Integer> f3 = CompletableFuture.supplyAsync(() -> 3);

CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.get(); // blocks until all three are done
System.out.println(f1.get() + f2.get() + f3.get()); // 6, results retrieved individually

Practical Usage: Building an Asynchronous Pipeline

A realistic pipeline chains several of these operators together: fetch data asynchronously, transform it, fetch something dependent on it, combine with another independent async result, and handle any failure along the way — all without any thread blocking until the very end (or without blocking at all, if the final result is itself handled asynchronously via thenAccept/thenRun rather than get()).

thenAccept() (a Consumer<T>, no return value) lets the pipeline's end be reacted to without ever calling the blocking get() — the calling thread never waits.

CompletableFuture<String> pipeline = fetchUserIdAsync()
    .thenCompose(this::fetchUserProfileAsync)   // dependent async step
    .thenApply(profile -> profile.name().toUpperCase()) // sync transform
    .exceptionally(ex -> "UNKNOWN_USER");        // fallback on any failure in the chain

pipeline.thenAccept(name -> System.out.println("Final: " + name)); // non-blocking consumption

Key Takeaways

Future<V> is a minimal handle to an eventual result: get() (blocking), get(timeout), isDone(), cancel().

get() throws ExecutionException wrapping the task's actual failure, and can throw InterruptedException or CancellationException.

CompletableFuture<T> implements Future<T> and adds composable, non-blocking chaining: thenApply (transform), thenCompose (chain dependent async steps, avoids nested futures), thenCombine (merge two independent futures).

exceptionally() supplies a fallback on failure only; handle() runs on both success and failure, receiving both a result and a throwable slot.

allOf()/anyOf() combine many futures — allOf waits for all to finish, anyOf completes as soon as one does.

A full asynchronous pipeline can be built and consumed (e.g. via thenAccept) without the calling thread ever blocking on get().

Future<Integer> f = pool.submit(() -> { throw new IllegalStateException("bad"); }); f.get(); // catches IllegalStateException directly -- won't compile / won't match try { f.get(); } catch (ExecutionException e) { Throwable cause = e.getCause(); // the actual IllegalStateException }

Future.get() always wraps a task's thrown exception in ExecutionException; the original exception is retrieved via getCause(), not caught directly.

CompletableFuture<Order> orderFuture = userFuture.thenApply(user -> fetchLatestOrderAsync(user.id())); // type is CompletableFuture<CompletableFuture<Order>> -- awkward nesting

CompletableFuture<Order> orderFuture = userFuture.thenCompose(user -> fetchLatestOrderAsync(user.id())); // correctly flattened to CompletableFuture<Order>

Use thenCompose (not thenApply) when the transformation function itself returns a CompletableFuture/CompletionStage, to avoid a doubly-nested future type.

CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> mayFail()); int result = cf.get(); // exception simply propagates out with no fallback

CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> mayFail()) .exceptionally(ex -> DEFAULT_VALUE); // graceful fallback baked into the pipeline

Without exceptionally() or handle() in the chain, any failure simply propagates out through get() as an ExecutionException, forcing every caller to handle it defensively instead of the pipeline defining a sensible fallback once.

What does Future.get() throw if the underlying task threw an exception, and how do you access the original exception?

It throws ExecutionException, which wraps the task's actual thrown exception; the original is retrieved via ExecutionException.getCause(). get() can also throw InterruptedException (if the waiting thread is interrupted) and CancellationException (if the task was cancelled).

What are the two main limitations of plain Future that CompletableFuture addresses?

Plain Future provides no way to register a callback for when the task completes (you can only block on get()), and no way to chain or combine it with other asynchronous operations without manually blocking in between. CompletableFuture adds composable operators (thenApply, thenCompose, thenCombine, etc.) that let pipelines be built without blocking until the very end.

When should thenCompose be used instead of thenApply?

When the function passed to it itself returns a CompletableFuture/CompletionStage — that is, the next step is itself an asynchronous operation. Using thenApply in that situation produces an awkward nested CompletableFuture<CompletableFuture<R>>; thenCompose flattens it into a single CompletableFuture<R>.

What is thenCombine used for, and what is its key behavioral property regarding the two futures involved?

It merges the results of two independent CompletableFutures once both have completed, using a supplied BiFunction. Its key property is that the two futures can execute concurrently and independently of each other — thenCombine only synchronizes at the point of combining their already-computed results.

What's the difference between exceptionally() and handle() on a CompletableFuture?

exceptionally() only runs if an earlier stage failed, supplying a fallback value of the same type and being skipped entirely on success. handle() always runs, whether the chain succeeded or failed, receiving both a result slot and a throwable slot (exactly one of which is null), letting one callback branch on either outcome.

What does CompletableFuture.allOf(f1, f2, f3) return, and how do you get the individual results afterward?

It returns a CompletableFuture<Void> that completes once all of f1, f2, and f3 have completed; it does not itself carry their individual results. After the returned future completes, each individual result is retrieved by calling get() on the original f1, f2, f3 respectively (which will return immediately since they're already done).

How can a CompletableFuture pipeline's final result be consumed without ever calling the blocking get() method?

By registering a terminal, non-blocking callback such as thenAccept(Consumer<T>) (consume the result, no return value) or thenRun(Runnable) (run regardless of the result's value), which are invoked once the pipeline completes without requiring the calling thread to block and wait.

Write code using CompletableFuture that fetches two independent numbers asynchronously and prints their sum, without ever calling get().

import java.util.concurrent.CompletableFuture; public class AsyncSum { public static void main(String[] args) throws InterruptedException { CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 15); CompletableFuture<Integer> b = CompletableFuture.supplyAsync(() -> 27); a.thenCombine(b, Integer::sum).thenAccept(sum -> System.out.println("Sum: " + sum)); Thread.sleep(200); // just to let the async chain finish before main() exits in this demo } } a and b compute concurrently on the common ForkJoinPool; thenCombine waits for both and adds their results (42), and thenAccept prints 'Sum: 42' — all without the main thread ever calling the blocking get() method (the Thread.sleep here is only to keep the demo's main() alive long enough to see the output, not part of the async logic itself).

What does this print, and why doesn't get() throw an exception here?

Prints -1. supplyAsync's supplier throws RuntimeException, which propagates as the stage's failure; exceptionally() catches that failure and supplies the fallback value -1, producing a normally-completed CompletableFuture<Integer> whose value is -1 — so by the time get() is called, the future is not in a failed state at all, and get() returns -1 normally instead of throwing ExecutionException.

Write a method that submits a Callable to an ExecutorService and waits at most 200 milliseconds for the result, printing a fallback message on timeout, using plain Future (not CompletableFuture).

import java.util.concurrent.*; public class TimeoutDemo { public static void main(String[] args) throws InterruptedException, ExecutionException { ExecutorService pool = Executors.newSingleThreadExecutor(); Future<Integer> future = pool.submit(() -> { Thread.sleep(1000); // deliberately slower than our timeout return 99; }); try { System.out.println("result: " + future.get(200, TimeUnit.MILLISECONDS)); } catch (TimeoutException e) { System.out.println("timed out waiting for result"); future.cancel(true); // attempt to stop the still-running task } finally { pool.shutdown(); } } }

The task sleeps for 1000ms but get() only waits up to 200ms, so a TimeoutException is thrown and caught, printing 'timed out waiting for result'; future.cancel(true) then attempts to interrupt the still-sleeping task (Thread.sleep responds to interruption by throwing InterruptedException inside it, ending the task early).

CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> {
    if (true) throw new RuntimeException("oops");
    return 1;
}).exceptionally(ex -> -1);
System.out.println(cf.get());