Skip to content

Java CompletableFuture: Chaining Async Operations with thenCompose

BackendBytes Engineering Team
BackendBytes Engineering Team
15 min read
Java CompletableFuture: Chaining Async Operations with thenCompose
4
Part of Series: Java in Production 2026

Lesson 4 of 6

Key Takeaways

  • thenCompose flattens async chains (T → CompletableFuture<U> → CompletableFuture<U>); thenApply nests them (T → CompletableFuture<U>), forcing .get().get() to unwrap
  • allOf runs N futures in parallel; total time becomes max(durations), not sum — checkpoint wait pattern with .join() inside the next stage guarantees all are done
  • Never use the default ForkJoinPool for I/O work; create a named FixedThreadPool so you can debug thread exhaustion in production logs
  • orTimeout(duration) fails fast with TimeoutException; completeOnTimeout(fallback) silently returns a default — choose based on whether the caller needs to know about the timeout

320 + 280 + 890 = 1,490 ms p99 — for three checkout calls that never needed each other. Fetch user, fetch cart, fraud check, all run in sequence and chained with thenApply instead of run in parallel with allOf. None of the three needs the others' output. One method swap drops total latency to roughly the slowest call: 890 ms. We debugged this exact pattern on multiple production Java services.

But orchestrating async work requires more: chaining dependent operations, error handling, timeouts, and thread pool management. Here's how to do it right.

TL;DR

thenCompose flattens async chains (T → CompletableFuture<U> → CompletableFuture<U>)[Java CompletableFuture]. Use it to chain operations where step B needs step A's result. thenApply is for synchronous transformations. Always use explicit executors for I/O work and add timeouts to every external call.

  • Use thenCompose when the next operation is async; thenApply for sync transformations
  • allOf runs independent futures in parallel; total time = max of individual times
  • Set timeouts with orTimeout (fail-fast) or completeOnTimeout (graceful degradation)
  • Never use the default ForkJoinPool for network calls; create a named FixedThreadPool instead
graph LR
    Start[Need async chain?] --> Q1{Operation type?}
    Q1 -->|"sync transform<br/>(T → U)"| Apply[thenApply<br/>cheap, sync]
    Q1 -->|"async call<br/>(T → CF&lt;U&gt;)"| Compose[thenCompose<br/>flattens nested futures]
    Q1 -->|"N independent calls"| AllOf[allOf<br/>total = max&#40;durations&#41;]
    Q1 -->|"two-future merge"| Combine[thenCombine]
    Compose --> Pool{I/O bound?}
    AllOf --> Pool
    Pool -->|Yes| Named[Named FixedThreadPool<br/>not default ForkJoinPool]
    Pool -->|No, CPU only| FJP[ForkJoinPool OK]
    style Apply fill:#efe
    style Compose fill:#efe
    style AllOf fill:#efe
    style Named fill:#fee

The diagram is the picker: choose the combinator by what kind of step you're chaining, not what's most familiar. The hidden trap most teams hit is thenApply returning a CompletableFuture — that gives you CompletableFuture<CompletableFuture<U>> and forces .get().get() to unwrap. thenCompose is the flatten that prevents the nesting. [Java CompletableFuture]

The Quick Start: thenApply vs thenCompose vs allOf

[Java CompletableFuture]
OperationInputOutputUse Case
thenApplyT → UCompletableFuture<U>Transform a value synchronously (string format, type cast, calculation)
thenComposeT → CompletableFuture<U>CompletableFuture<U> (flattened)Chain async calls — step B needs step A's result. Avoids nesting.
allOf[CompletableFuture<T>, ...]CompletableFuture<Void>Run independent calls in parallel; wait for all. No automatic result collection.
thenCombineTwo CompletableFuture<T>CompletableFuture<R>Merge two futures when both complete; cleaner than allOf for pairs.

thenCompose is the key: it chains async operations without nesting futures. When you call thenApply with a function that returns a CompletableFuture, you get a nested future (a future of a future). That's useless — you need to unwrap it with .get().get(), which defeats async. thenCompose does the unwrapping for you, returning a flat CompletableFuture<U>.

// WRONG: nesting (without thenCompose)
CompletableFuture<CompletableFuture<UserProfile>> nested = userId
    .thenApply(id -> fetchProfileAsync(id)); // Returns double-wrapped future
// To use it: nested.get().get() — clumsy, blocks, and breaks error handling
 
// RIGHT: thenCompose flattens
CompletableFuture<UserProfile> flattened = userId
    .thenCompose(id -> fetchProfileAsync(id)); // Returns flat CompletableFuture<UserProfile>
// Much cleaner. Error handling works correctly. Async semantics are preserved.

This distinction is subtle but critical: thenApply is for sync transformations (you transform a value instantly). thenCompose is for async chains (you start a new async operation that depends on the previous result). Getting this wrong is a common bug in production — accidentally nesting futures leads to .get().get() calls and broken error handling.

Chaining Dependent Operations with thenCompose

[Java CompletableFuture]

Use thenCompose when step B needs step A's result. The lambda returns a CompletableFuture, and thenCompose unwraps it automatically:

public class CheckoutService {
    record User(String id, String paymentId, double creditLimit) {}
    record Reservation(String id) {}
    record CheckoutResult(String orderId, String transactionId) {}
 
    private final UserService userService;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
 
    public CompletableFuture<CheckoutResult> processCheckout(String orderId) {
        record Context(User user, Reservation reservation) {}
 
        return userService.fetchUserAsync(orderId)
            .thenCompose(user ->
                inventoryService.reserveAsync(orderId, user.creditLimit())
                    .thenApply(reservation -> new Context(user, reservation))
            )
            .thenCompose(ctx ->
                paymentService.chargeAsync(ctx.user().paymentId())
                    .thenApply(payment -> new CheckoutResult(orderId, payment.transactionId()))
            )
            .orTimeout(10, TimeUnit.SECONDS);
    }
}

The record carries state through the chain immutably, preserving thread safety. Each thenCompose waits for the previous step before starting the next — the pipeline reads top-to-bottom, matching your business logic flow.

Running Operations in Parallel with allOf

[Java CompletableFuture]

The sequential vs parallel latency math in one picture — sum vs max:

graph TB
    subgraph Seq[BEFORE — sequential thenApply chain]
        R1[Request received] --> P1[Profile fetch<br/>320 ms]
        P1 --> O1[Orders fetch<br/>280 ms]
        O1 --> A1[Account balance<br/>890 ms]
        A1 --> Resp1[Response<br/>p99 = 1490 ms<br/>SUM of all]
    end
    subgraph Par[AFTER — allOf fan-out]
        R2[Request received] --> P2[Profile fetch<br/>320 ms]
        R2 --> O2[Orders fetch<br/>280 ms]
        R2 --> A2[Account balance<br/>890 ms]
        P2 --> Join[allOf.thenApply<br/>combines results]
        O2 --> Join
        A2 --> Join
        Join --> Resp2[Response<br/>p99 = 890 ms<br/>MAX of all]
    end
    style Resp1 fill:#fdd
    style Resp2 fill:#dfd

When multiple operations don't depend on each other, start them all at once with allOf. Total time becomes the max of individual times, not their sum. This is the 40% latency win from the opening: three calls that used to run sequentially (320ms + 280ms + 890ms = 1,490ms) now run in parallel (max = 890ms). [Java CompletableFuture]

public class DashboardService {
    record Dashboard(UserProfile profile, List<Order> orders, double balance) {}
 
    private final ProfileService profileService;
    private final OrderService orderService;
    private final AccountService accountService;
 
    public CompletableFuture<Dashboard> getDashboard(String userId) {
        // All three start immediately — executor threads dispatch them concurrently
        CompletableFuture<UserProfile> profile = profileService.fetchAsync(userId);
        CompletableFuture<List<Order>> orders = orderService.fetchRecentAsync(userId);
        CompletableFuture<Double> balance = accountService.fetchBalanceAsync(userId);
 
        // Wait for all to complete, then combine results
        return CompletableFuture.allOf(profile, orders, balance)
            .thenApply(v -> new Dashboard(
                profile.join(),  // join() is safe: allOf guarantees all are complete
                orders.join(),
                balance.join()
            ))
            .orTimeout(5, TimeUnit.SECONDS);  // timeout for the entire fan-out
    }
}

allOf returns CompletableFuture<Void>. After it completes, call .join() on the individual futures inside the next stage — they're already done, so join() returns immediately without blocking. There's no automatic result collection in allOf; you have to pull results from the original futures.

For two futures, thenCombine is cleaner and avoids the .join() calls:

profileFuture
    .thenCombine(ordersFuture, (profile, orders) ->
        new Dashboard(profile, orders)
    );

thenCombine runs both futures in parallel and passes their results directly to the combining function. Use thenCombine when you have 2–3 independent futures. Use allOf when you have more.

Error Handling: Recovery and Propagation

[Java CompletableFuture]

Choose how to handle each failure: recover with exceptionally, transform with handle, or observe with whenComplete:

// Recovery: return default on failure
profileService.fetchAsync(userId)
    .exceptionally(ex -> UserProfile.defaultProfile(userId));
 
// Transformation: convert success/failure to Result type
profileService.fetchAsync(userId)
    .handle((profile, ex) -> ex != null
        ? Result.failure(ex.getMessage())
        : Result.success(profile));
 
// Observation: log without changing the exception
checkoutService.processCheckout(orderId)
    .whenComplete((result, ex) -> {
        if (ex != null) log.error("Checkout failed", ex);
    });

Critical rule: Never call .get() or .join() inside thenApply — it blocks the executor thread. Use thenCompose to chain async calls. Blocking threads starves other futures and causes OOM under load.

Thread Pool and Timeout Configuration

[Java CompletableFuture]

Never use the default ForkJoinPool for I/O work. The default pool is sized to match CPU cores (availableProcessors()-1 threads), and a single slow network call will starve all CPU-bound work. Create a named fixed thread pool with 50–200 threads for I/O, depending on your workload. Always add timeouts.

public class AsyncConfig {
    // I/O-bound work: many threads because they spend time waiting on network
    // Size depends on your latency budget. If avg response is 100ms and you make
    // 10 calls/sec, you need ~1 thread. If you make 1000 calls/sec, you need ~100.
    private final ExecutorService ioExecutor = Executors.newFixedThreadPool(
        100,
        Thread.ofPlatform().name("async-io-", 0).factory()
    );
 
    public CompletableFuture<UserProfile> fetchUserAsync(String userId) {
        return CompletableFuture.supplyAsync(
            () -> httpClient.fetch("/users/" + userId, UserProfile.class),
            ioExecutor
        ).orTimeout(3, TimeUnit.SECONDS);
    }
}

The thread pool name (async-io-) shows up in thread dumps and logs — critical for production debugging. When a thread dump shows 50 threads stuck in async-io-*, you know immediately it's an I/O pool stall. Anonymous thread names hide the problem and make diagnosis impossible. Thread naming is one of those small details that saves you hours of debugging at 3am.

Timeout strategy: Use orTimeout for critical data (fail fast with TimeoutException). Use completeOnTimeout for optional data (show degraded experience with a default value). Never create a CompletableFuture without a timeout — a hung downstream service will exhaust your thread pool and bring down your entire system. Even a 3-second timeout at 1000 reqs/sec will tie up 3000 executor threads and exhaust heap memory, crashing the JVM with OOM.

Common Mistakes in Production

Blocking inside stages. The most common bug: calling .get() or .join() inside a thenApply or other stage. This blocks the thread running the stage, which could be a scarce resource.

// WRONG: blocks the executor thread
CompletableFuture<Result> bad = getUserAsync()
    .thenApply(user -> {
        // This blocks! If 100 users are fetched, all 100 executor threads are blocked.
        List<Order> orders = orderService.fetchOrdersSync(user.id());
        return new Result(user, orders);
    });
 
// RIGHT: chain async calls with thenCompose
CompletableFuture<Result> good = getUserAsync()
    .thenCompose(user ->
        orderService.fetchOrdersAsync(user.id())
            .thenApply(orders -> new Result(user, orders))
    );

Forgetting timeouts. A stuck downstream service will hold futures open indefinitely, eventually exhausting your executor thread pool. Cascading failures ensue.

// WRONG: no timeout, can hang forever
CompletableFuture<Data> noTimeout = externalService.fetchAsync();
 
// RIGHT: always set a timeout
CompletableFuture<Data> withTimeout = externalService.fetchAsync()
    .orTimeout(5, TimeUnit.SECONDS);

Using the default ForkJoinPool for I/O. By default, supplyAsync() uses the common ForkJoinPool, which is tiny (availableProcessors()-1 threads). One slow I/O call starves all CPU-bound work in your entire JVM. This is a hard deadlock in disguise.

// WRONG: steals threads from CPU-bound work
CompletableFuture<UserProfile> bad = CompletableFuture.supplyAsync(() ->
    httpClient.fetch("/users/123", UserProfile.class)
);
 
// RIGHT: explicit I/O executor
CompletableFuture<UserProfile> good = CompletableFuture.supplyAsync(
    () -> httpClient.fetch("/users/123", UserProfile.class),
    ioExecutor  // 100+ threads, explicitly named
);

Swallowing exceptions in handle. If you return a default value from handle when an exception occurs, downstream stages don't see the error. Failures silently vanish and your code keeps running with garbage data.

// WRONG: exception disappears
profileService.fetchAsync(userId)
    .handle((profile, ex) -> {
        if (ex != null) return null; // downstream gets null, no indication of error
        return profile;
    });
 
// RIGHT: preserve the error or transform it explicitly
profileService.fetchAsync(userId)
    .handle((profile, ex) -> {
        if (ex != null) {
            log.error("Profile fetch failed", ex);
            throw new CompletionException(ex);
        }
        return profile;
    });

Mixing Critical and Optional Data

Use orTimeout for critical data (fail fast), completeOnTimeout for optional data (degrade gracefully):

// Critical: fail if inventory times out
CompletableFuture<InventoryStatus> inventory =
    inventoryService.checkAsync(productId)
        .orTimeout(2, TimeUnit.SECONDS);
 
// Optional: empty list if recommendations time out
CompletableFuture<List<Product>> recommendations =
    recommendationService.fetchAsync(userId)
        .completeOnTimeout(Collections.emptyList(), 500, TimeUnit.MILLISECONDS);
 
// Combine: users get a working page even if optional data times out
CompletableFuture.allOf(inventory, recommendations)
    .thenApply(v -> new ProductPage(
        inventory.join(),
        recommendations.join()  // empty if it timed out
    ));

The page responds in max(2s, 500ms) = 2s, not 2.5s if both timed out sequentially.

Production Checklist

Before shipping async code to production:

  • Every external call has a timeout (.orTimeout() or .completeOnTimeout()) — no exceptions
  • Thread pool is explicitly named and sized for I/O (50–200 threads for network calls)
  • No .get() or .join() calls inside stages — use thenCompose to chain async work
  • Exception handling is explicit: exceptionally for recovery, handle for transformation, never silently swallow errors
  • Use records or immutable objects to pass state through chains — no mutation
  • Test timeout and failure paths — they're the most fragile in production
  • Thread dump shows your pool names (async-io-*), not anonymous pool-* threads
  • Metrics and logging capture completion times and error rates for every stage
  • Critical vs. optional futures use appropriate timeouts (orTimeout vs. completeOnTimeout)

Combining Virtual Threads with CompletableFuture

Virtual threads (Java 21+) change the cost model for blocking I/O. A platform thread costs roughly 1 MB of stack and a kernel thread descriptor; a virtual thread costs a few kilobytes of heap and parks on a continuation when it blocks. That means the old rule — "never block in a future stage" — softens when the executor backing the future is a virtual-thread executor. You can call a synchronous JDBC driver inside a stage without starving the carrier pool, because the runtime unmounts the virtual thread the moment the JDBC socket blocks and remounts another ready continuation in its place.

The practical pattern: keep your async pipeline shape (fan-out, timeouts, error handling) but switch the executor from a fixed platform pool to a virtual-thread executor. The same chain code now scales to tens of thousands of concurrent in-flight requests without thread-pool tuning.

public class VirtualThreadAsyncConfig {
    // One executor per virtual thread; runtime multiplexes onto a small carrier pool.
    // No sizing decision required — virtual threads are cheap enough to spawn per task.
    private final ExecutorService vtExecutor =
        Executors.newVirtualThreadPerTaskExecutor();
 
    public CompletableFuture<UserProfile> fetchUserAsync(String userId) {
        return CompletableFuture.supplyAsync(() -> {
            // Synchronous JDBC call is fine here — virtual thread unmounts on socket wait.
            try (var conn = dataSource.getConnection();
                 var ps = conn.prepareStatement("SELECT id, name, plan FROM users WHERE id = ?")) {
                ps.setString(1, userId);
                try (var rs = ps.executeQuery()) {
                    if (!rs.next()) throw new NotFoundException(userId);
                    return new UserProfile(rs.getString(1), rs.getString(2), rs.getString(3));
                }
            } catch (SQLException e) {
                throw new CompletionException(e);
            }
        }, vtExecutor).orTimeout(2, TimeUnit.SECONDS);
    }
}

The combinator chain is unchanged — what shifts is the executor. There are still two pinning hazards to remember: synchronized blocks and JNI calls keep a virtual thread bound to its carrier, and a long-running CPU loop inside a stage will hog the carrier just like a platform thread would. For pure CPU work, keep a small named platform executor; for any I/O, route through the virtual-thread executor.

Structured Concurrency (JEP 505, Preview in JDK 25)

Structured concurrency has run through several preview rounds and is previewed again as JEP 505 in JDK 25 (the September 2025 LTS) — still a preview API, so you compile with --enable-preview. It treats a group of concurrent subtasks as a single unit of work with a defined lifetime. The motivation is straightforward: a vanilla fan-out leaks subtasks on cancellation, hides errors until you remember to call .join(), and lacks a clean parent-child relationship for tracing. StructuredTaskScope fixes this by binding subtasks to a lexical scope that the parent must close, with built-in Joiner policies for "all must succeed" and "first success wins."

import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
 
public class StructuredDashboardService {
    record Dashboard(UserProfile profile, List<Order> orders, double balance) {}
 
    public Dashboard getDashboard(String userId) throws InterruptedException {
        // open(...) replaces the old `new ShutdownOnFailure()` constructor (removed in JDK 25).
        // awaitAllSuccessfulOrThrow() fits subtasks with different result types; the config
        // function carries the timeout that joinUntil() used to take.
        try (var scope = StructuredTaskScope.open(
                Joiner.<Object>awaitAllSuccessfulOrThrow(),
                cf -> cf.withTimeout(Duration.ofSeconds(5)))) {
 
            // Fork three subtasks — each runs on its own virtual thread.
            var profileTask = scope.fork(() -> profileService.fetchSync(userId));
            var ordersTask  = scope.fork(() -> orderService.fetchRecentSync(userId));
            var balanceTask = scope.fork(() -> accountService.fetchBalanceSync(userId));
 
            // join() waits for all to finish or any to fail; it throws FailedException
            // on the first failure and TimeoutException if the 5s budget elapses.
            scope.join();
 
            return new Dashboard(profileTask.get(), ordersTask.get(), balanceTask.get());
        }
    }
}

awaitAllSuccessfulOrThrow() cancels the surviving subtasks the instant one fails, eliminating the wasted-work problem of allOf, where slow successful tasks keep running after a fast failure. Joiner.anySuccessfulResultOrThrow() does the inverse for redundant calls (hedged reads, primary-replica fallback) — it returns the first success and cancels the rest. The scope guarantees that every forked subtask is either complete or cancelled by the time the try-with-resources block exits, so there are no orphaned tasks to leak threads or log noise after the request returns.

The trade-off is that structured concurrency expects synchronous subtask bodies and virtual threads underneath. If your codebase is already deeply invested in async chains, you don't migrate everything overnight — you can use StructuredTaskScope at request entry points and keep the existing chains for shared library code that already hands back futures.

Callback Chains vs Structured Concurrency vs Reactive Mono

Put the three styles side by side for a fan-out-of-three (profile + orders + balance, all I/O-bound) and the differences are more about executor and ergonomics than raw speed:

StyleCode shapeWhat dominates its latency
Callback chain — allOf + platformthenCompose / allOfWorker-pool queuing: blocked threads cap concurrency hard
Callback chain — allOf + virtualSame code, VT executorI/O latency itself — threads no longer the bottleneck
Structured concurrency — JoinerStructuredTaskScope.openI/O latency; less future-bookkeeping overhead than allOf
Reactive — Project Reactor Mono.zipMono.zip + subscribeOnScheduler hops; already non-blocking, so VTs add little

The result that actually matters: switching the executor from a bounded platform-thread pool to virtual threads is worth more than picking a different abstraction — the same callback-chain code that queues on a 200-thread pool stops queuing entirely on a virtual-thread executor[JEP 444, 2023]. Among the virtual-thread variants the differences are small: structured concurrency skips some of allOf's future-bookkeeping, while reactive Mono benefits least from virtual threads because its work is already non-blocking — and pays an ergonomics tax the moment you mix in a synchronous JDBC call. Don't take ranking from prose: the harness in the Go vs Java article adapts directly to measure these four on your own JDK and I/O profile. [Java CompletableFuture]

// Equivalent fan-out in three styles — same I/O, different surface.
 
// 1. Callback chain
CompletableFuture<UserProfile> profile = profileService.fetchAsync(userId);
CompletableFuture<List<Order>> orders  = orderService.fetchRecentAsync(userId);
CompletableFuture<Double>      balance = accountService.fetchBalanceAsync(userId);
CompletableFuture<Dashboard> cf =
    CompletableFuture.allOf(profile, orders, balance)
        .thenApply(v -> new Dashboard(profile.join(), orders.join(), balance.join()))
        .orTimeout(5, TimeUnit.SECONDS);
 
// 2. Structured concurrency (JDK 25, --enable-preview)
try (var scope = StructuredTaskScope.open(
        Joiner.<Object>awaitAllSuccessfulOrThrow(),
        cf -> cf.withTimeout(java.time.Duration.ofSeconds(5)))) {
    var p = scope.fork(() -> profileService.fetchSync(userId));
    var o = scope.fork(() -> orderService.fetchRecentSync(userId));
    var b = scope.fork(() -> accountService.fetchBalanceSync(userId));
    scope.join();  // throws FailedException / TimeoutException
    return new Dashboard(p.get(), o.get(), b.get());
}
 
// 3. Reactive — Project Reactor
Mono<Dashboard> mono = Mono.zip(
        profileService.fetchMono(userId),
        orderService.fetchRecentMono(userId),
        accountService.fetchBalanceMono(userId))
    .map(t -> new Dashboard(t.getT1(), t.getT2(), t.getT3()))
    .timeout(java.time.Duration.ofSeconds(5));

Pick by team familiarity and the surrounding code. New code on JDK 21+ should default to structured concurrency for request handlers and keep callback chains only where you are wrapping a library that already returns futures. Reactive remains the right pick when the entire stack — controller, service, repository — speaks Reactor and you need backpressure end to end. Mixing all three in one request path is the worst outcome: the cognitive cost of switching mental models on every line outweighs any micro-benchmark win.

Frequently Asked Questions

What is the difference between thenApply and thenCompose?

thenApply takes a synchronous function (T → U) for transformations. thenCompose takes a function returning CompletableFuture (T → CompletableFuture<U>) and flattens the result. Use thenApply for transformations, thenCompose for chaining async calls to avoid nested futures.

How do I run multiple CompletableFutures in parallel?

Use allOf(future1, future2, future3) to start all concurrently. Total time becomes max(durations), not their sum. Inside the next stage, call .join() on each future to get results — allOf guarantees all are complete.

How do I add timeouts?

Use .orTimeout(duration, TimeUnit) to fail with TimeoutException, or .completeOnTimeout(fallbackValue, duration, TimeUnit) to return a default. Use orTimeout for critical data, completeOnTimeout for degraded experience.

Which executor should I use?

Never use the default ForkJoinPool for I/O work. Create a dedicated fixed thread pool for network calls (e.g., 100 threads). Name threads explicitly for debugging — anonymous pools hide issues in production.

Keep Reading

Was this article helpful?

Your feedback directly shapes our editorial depth and technical accuracy.

BackendBytes Engineering Team
BackendBytes Engineering Team

Engineering Team

A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.

Read Next