PrepZone Logo
PrepZone

CompletableFuture

Composing asynchronous work without blocking, combining results, and handling failure in the chain.

Why this matters

  • Future.get() blocks, which defeats the purpose of running work asynchronously. CompletableFuture is how you compose asynchronous steps without blocking any of them.
  • The method names follow a strict naming rule, and once you know the rule you can predict every one of the fifty-odd methods.
  • Choosing the wrong executor is the most common real-world mistake, and it fails in a way that is hard to debug.

The problem it solves

Java
// With Future, you block to make a decision
Future<User> userFuture = executor.submit(() -> loadUser(id));
User user = userFuture.get();                              // thread parked here
Future<List<Order>> orders = executor.submit(() -> loadOrders(user));
List<Order> result = orders.get();                         // and again

Two sequential blocking waits. The calling thread does nothing useful for the whole duration.

Java
// With CompletableFuture, you describe the chain and return immediately
CompletableFuture<List<Order>> result = CompletableFuture
        .supplyAsync(() -> loadUser(id), executor)
        .thenCompose(user -> CompletableFuture.supplyAsync(() -> loadOrders(user), executor));

Nothing blocks. The chain runs as each stage completes, on whichever thread finished the previous stage.

supplyAsync — fetch user
supplyAsync — fetch orders
Build the responseRuns when both finish
Fallback valueFailure handled in the chain
Each stage declares what happens next instead of blocking for a result. Independent calls run in parallel and only join where you actually combine them.

Starting a chain

Java
CompletableFuture.supplyAsync(() -> loadUser(id));                  // returns a value
CompletableFuture.supplyAsync(() -> loadUser(id), executor);         // on your own pool
CompletableFuture.runAsync(() -> audit.record(id), executor);        // returns nothing

CompletableFuture.completedFuture(cachedUser);                        // already done
CompletableFuture.failedFuture(new TimeoutException());               // already failed, Java 9+

// Completed by something else entirely — a callback, a message listener
CompletableFuture<String> manual = new CompletableFuture<>();
messageBus.onReply(reply -> manual.complete(reply));

The naming rule

Every method name is built from two decisions, which makes the whole API predictable.

AspectWhat the callback receives and returnsWhich thread runs it
then + ApplyTakes the value, returns a new value — like mapno suffix: the thread that completed the previous stage
then + AcceptTakes the value, returns nothing — a side effectAsync: a pool thread, commonPool by default
then + RunTakes nothing, returns nothingAsync with an executor: your pool — the safe choice
then + ComposeTakes the value, returns another future — like flatMapsame three variants
then + CombineTakes two values from two futures, returns onesame three variants
  • then + Apply

    What the callback receives and returnsTakes the value, returns a new value — like map
    Which thread runs itno suffix: the thread that completed the previous stage
  • then + Accept

    What the callback receives and returnsTakes the value, returns nothing — a side effect
    Which thread runs itAsync: a pool thread, commonPool by default
  • then + Run

    What the callback receives and returnsTakes nothing, returns nothing
    Which thread runs itAsync with an executor: your pool — the safe choice
  • then + Compose

    What the callback receives and returnsTakes the value, returns another future — like flatMap
    Which thread runs itsame three variants
  • then + Combine

    What the callback receives and returnsTakes two values from two futures, returns one
    Which thread runs itsame three variants

Apply/Accept/Run/Compose/Combine says what the callback does; the Async suffix says where it runs.

Java
future.thenApply(user -> user.name());                       // String from User
future.thenAccept(user -> log.info(user.name()));             // consume
future.thenRun(() -> log.info("done"));                       // ignore the value

// Apply vs Compose — the distinction that matters
CompletableFuture<CompletableFuture<Order>> nested =
        userFuture.thenApply(user -> loadOrderAsync(user));   // wrong: doubly wrapped

CompletableFuture<Order> flat =
        userFuture.thenCompose(user -> loadOrderAsync(user)); // right: flattened

Combining futures

Java
// Two independent calls, combined when both finish
CompletableFuture<Profile> profile = CompletableFuture
        .supplyAsync(() -> loadUser(id), pool)
        .thenCombine(CompletableFuture.supplyAsync(() -> loadSettings(id), pool),
                     (user, settings) -> new Profile(user, settings));

// Many futures: wait for all of them
List<CompletableFuture<Order>> futures = ids.stream()
        .map(orderId -> CompletableFuture.supplyAsync(() -> loadOrder(orderId), pool))
        .toList();

CompletableFuture<List<Order>> all = CompletableFuture
        .allOf(futures.toArray(new CompletableFuture[0]))
        .thenApply(ignored -> futures.stream().map(CompletableFuture::join).toList());

// The first to finish wins — useful for racing replicas
CompletableFuture<Object> fastest = CompletableFuture.anyOf(primary, replica);

Handling failure

Java
// Recover with a fallback value
future.exceptionally(throwable -> {
    log.warn("falling back", throwable);
    return User.anonymous();
});

// See both outcomes and produce a value
future.handle((value, throwable) -> throwable == null ? value : User.anonymous());

// Observe both outcomes without changing the result — for logging
future.whenComplete((value, throwable) -> metrics.record(value, throwable));

// Recover asynchronously, Java 12+
future.exceptionallyCompose(throwable -> loadFromCacheAsync(id));

Which one to reach for

  • exceptionally — only runs on failure, returns a replacement value. The usual fallback.
  • handle — always runs, receives value and throwable, returns a new value. Use it to turn a failure into a result.
  • whenComplete — always runs, cannot change the result. Use it for logging, metrics and cleanup.

An exception thrown inside any stage short-circuits the rest of the chain: every downstream thenApply is skipped and the failure propagates to the first handler that can deal with it.

Java
CompletableFuture.supplyAsync(() -> { throw new IllegalStateException("boom"); })
        .thenApply(value -> value + "!")        // skipped entirely
        .exceptionally(throwable -> "recovered: " + throwable.getCause().getMessage())
        .thenAccept(System.out::println);        // prints the recovery

Timeouts

Java
future.orTimeout(2, TimeUnit.SECONDS);                       // fail after 2s, Java 9+
future.completeOnTimeout(User.anonymous(), 2, TimeUnit.SECONDS);   // default after 2s

// The composed version of the same idea
CompletableFuture<User> safe = loadUserAsync(id)
        .orTimeout(2, TimeUnit.SECONDS)
        .exceptionally(throwable -> User.anonymous());

Before Java 9 this required a scheduled executor to complete the future manually, so these two methods are a genuine reason to be on a modern baseline.

Getting the result

Java
future.join();                                // unchecked CompletionException — preferred in chains
future.get();                                 // checked ExecutionException and InterruptedException
future.get(2, TimeUnit.SECONDS);              // with a timeout
future.getNow(User.anonymous());              // the value if ready, otherwise the default — never blocks
future.isDone();
future.cancel(true);

Both join and get block, so they belong at the edge of your application — in a test, or in a main method that must not exit early — not in the middle of a chain.

A realistic composition

Java
public CompletableFuture<Dashboard> buildDashboard(String userId) {
    CompletableFuture<User> user = CompletableFuture.supplyAsync(() -> userService.load(userId), ioPool);

    CompletableFuture<List<Order>> orders = user
            .thenComposeAsync(u -> CompletableFuture.supplyAsync(() -> orderService.recent(u), ioPool), ioPool)
            .exceptionally(throwable -> List.of());                 // degrade, do not fail

    CompletableFuture<Stats> stats = CompletableFuture
            .supplyAsync(() -> statsService.summary(userId), ioPool)
            .completeOnTimeout(Stats.empty(), 500, TimeUnit.MILLISECONDS);

    return user.thenCombine(orders, Dashboard::new)
            .thenCombine(stats, Dashboard::withStats)
            .orTimeout(3, TimeUnit.SECONDS)
            .whenComplete((dashboard, throwable) -> metrics.recordDashboard(throwable));
}

Three remote calls, two of which run concurrently, each with its own degradation strategy, an overall timeout, and metrics — and no thread blocks at any point.

Common misreadings

  • "thenApply and thenCompose are interchangeable." thenApply with a future-returning callback gives you a nested future.
  • "allOf returns the results." It returns CompletableFuture<Void>; collect the values from the original futures afterwards.
  • "The default pool is fine." commonPool() is shared JVM-wide and sized to cores minus one. Pass your own executor for blocking work.
  • "join() and get() throw the original exception." Both wrap it — check getCause().
  • "A non-Async callback runs on the original submitting thread." It runs on whichever thread completed the previous stage, or the calling thread if it was already complete.
  • "Chaining means it is non-blocking end to end." One get() or join() in the middle reintroduces blocking.
  • "whenComplete can change the result." It cannot. Use handle for that.

Quick recall

Everything you need if you only revisit this box.

  • CompletableFuture composes asynchronous steps without blocking; Future.get() blocks.
  • The naming rule: Apply returns a value, Accept consumes, Run ignores, Compose flattens a future, Combine merges two. The Async suffix moves execution to a pool.
  • thenApply is map, thenCompose is flatMap.
  • thenCombine joins two futures, allOf waits for many (returning Void), anyOf takes the first.
  • Failure handling: exceptionally (on failure), handle (always, can change the result), whenComplete (always, cannot change it).
  • Exceptions are wrapped — join() throws CompletionException, get() throws ExecutionException; read getCause().
  • orTimeout and completeOnTimeout since Java 9.
  • Always pass your own executor for blocking work; the default commonPool() is JVM-wide and tiny.

Test yourself

Answer these before moving on — recall is what makes it stick.