Java Concurrency & Async Programming · Lesson 7

CompletableFuture Composition, Errors, and Timeouts

Compose Java CompletableFuture stages deliberately across executors, failures, timeouts, cancellation, context, and external side-effect boundaries.

CompletableFuture represents a result pipeline, not a complete concurrency policy. Each stage has an execution rule, failure rule, context boundary, and cancellation limitation. A fluent chain can still block the common pool, swallow the original error, or allow remote work to continue after the caller leaves.

Quick answer

Use thenApply for a synchronous transformation and thenCompose when the function already returns another asynchronous stage. Choose executors explicitly for blocking work. Model exceptional completion deliberately with handle, whenComplete, or exceptionally according to whether the stage observes, transforms, or recovers. Timeouts complete the future’s observation path; CompletableFuture.cancel() does not guarantee reliable interruption or termination of arbitrary underlying work.

Learning objectives

  • Build a composed pipeline whose executor, result shape, error transformation, and deadline behavior remain explicit.
  • Separate future completion and cancellation from the durable outcome of database and external side effects.

Prerequisites

Read Java Interruption, Cancellation, and Deadlines and ExecutorService, Thread Pools, and Work Queues. Follow the module in the Java Concurrency course or browse the topic cluster.

Composition model

thenApply(order -> receipt) maps one value to another. If the mapping returns CompletionStage<Receipt>, thenApply produces a nested stage; thenCompose flattens it. thenCombine joins independent results, but both operations may continue even when one result is no longer useful unless their owners support cancellation.

Non-Async continuations can run in the thread that completes the previous stage. Async variants without an executor generally use the common pool. That default is risky for blocking database or HTTP work because unrelated application tasks share it. Supply an owned executor or, for suitable blocking code on Java 21, use a virtual-thread-per-task executor while limiting the scarce downstream resource separately.

Java 21 baseline example

import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;

final class ProfileFlow {
    private final Executor blockingExecutor;

    ProfileFlow(Executor blockingExecutor) {
        this.blockingExecutor = blockingExecutor;
    }

    CompletableFuture<String> load(String userId) {
        return CompletableFuture
            .supplyAsync(() -> "profile:" + userId, blockingExecutor)
            .thenApply(String::toUpperCase)
            .orTimeout(Duration.ofSeconds(2).toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
            .whenComplete((value, failure) -> {
                if (failure != null) recordFailureCategory(failure);
            });
    }

    private void recordFailureCategory(Throwable failure) {
        // Record a bounded category; do not use user IDs as metric labels.
    }
}

The string operation stands in for blocking work; production code must give the underlying client its own remaining deadline. orTimeout can complete the returned stage exceptionally, but it does not prove the supplier stopped. completeOnTimeout returns a fallback and has the same underlying-work boundary.

Error operators are not interchangeable

whenComplete observes a value or failure and normally preserves the outcome, though its callback can itself fail. exceptionally maps a failure to a fallback value. handle maps either success or failure. Recover only when the fallback is truthful and safe. Returning an empty order after a database failure may convert an outage into false “no orders” data.

Exceptions are often wrapped in CompletionException or ExecutionException; classify root causes without exposing internal messages to callers. Keep correlation in protected logs and preserve the original causal chain.

Failure scenario: timeout, retry, duplicate email

A future triggers an email provider and applies orTimeout(500 ms). At 500 ms the web request fails and retries. The first provider call was still running and both sends succeed. The future timeout governed waiting, not the provider’s effect. A stable notification ID, provider-side idempotency when available, an outbox, and reconciliation are needed. Canceling the CompletableFuture alone is not reliable stopping of the underlying arbitrary work.

Spring and context boundaries

Combining @Async and CompletableFuture can add two executor decisions. Document which component owns execution. A transaction opened in the caller does not automatically follow a stage onto another thread. SecurityContext, MDC, and ThreadLocal state also do not automatically cross every stage; pass essential identity explicitly and use narrowly scoped propagation with cleanup. Never infer authorization from copied diagnostic context.

Common misconceptions

  • Async means “use the best executor”; it often means a default you have not reviewed.
  • A completed future does not prove every remote effect completed exactly once.
  • join() is not nonblocking; it waits and wraps failure.
  • A timeout is not cancellation proof.
  • An exception fallback is not safe if it changes business meaning silently.

Production validation

Test success, synchronous exception, asynchronous exception, timeout before start, timeout during dependency work, rejected execution, and shutdown. Record stage failure categories, executor saturation, work completed after caller deadline, and dependency outcomes. Use traces to connect stages while avoiding high-cardinality metrics. Inspect the common pool and owned executors in thread dumps and JFR. Reconcile side effects from their authoritative stores.

Continue with Java Virtual Threads for Backend Services and compare the timeout scenarios in the Java Concurrency Budget Lab.

FAQ

Does thenCompose make work parallel?

No. It flattens a dependent asynchronous result. Parallelism depends on how the stages are created and scheduled.

Does cancel(true) interrupt a CompletableFuture supplier?

Do not rely on it as a general guarantee. The documented cancellation behavior does not make arbitrary underlying computation reliably stoppable.

Should blocking stages use the common pool?

Usually use an explicitly owned execution strategy. Validate it against the workload and downstream capacity instead of consuming a shared default silently.

Use the Java Concurrency learning path, Java Concurrency topic, and Background Jobs Explained for durable asynchronous ownership.

Official sources

Knowledge check

Check your understanding

Answer both questions correctly to mark this lesson as mastered. You can retry without penalty.

1. A stage-producing function is applied to a prior result; which operator avoids a nested future?

2. What does orTimeout prove about a blocking supplier after the returned stage times out?