← All Blogs

April 28, 2026

Java CompletableFuture: The Distinctions That Actually Matter

CompletableFuture has a large API surface, and most of it is fine. But there are a handful of method pairs that look interchangeable until you swap one for the other and watch your program print something like:

Chained async result: java.util.concurrent.CompletableFuture@1fb3ebeb[Not completed]

That is the output you get when you use thenApply where you needed thenCompose. It is a real output from a real mistake, and it points at a pattern that repeats across several other places in the API.

This post covers all of them. Same code, wrong method, clear explanation of what went wrong and why.


The Demo Setup

All the examples below use a simple Product record and a helper to simulate slow I/O:

record Product(String id, String name, BigDecimal price, String category, boolean inStock) {}

static void simulateDelay(int ms) {
    try {
        TimeUnit.MILLISECONDS.sleep(ms);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

1. thenApply vs thenCompose

This is the one that produces the [Not completed] output.

Both methods let you chain something onto a CompletableFuture once it resolves. The difference is what your callback returns.

thenApply — your callback returns a plain value

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    simulateDelay(300);
    return new Product("p2", "Desktop", new BigDecimal("1999.99"), "Electronics", true);
})
.thenApply(product -> product.name().toUpperCase());  // String → String
// type: CompletableFuture<String> ✓

thenApply is a synchronous transform. Your lambda takes a T and returns an R. The CompletableFuture wraps that R for you. Think of it as .map() on a stream — you are transforming the value, not starting new async work.

thenCompose — your callback returns a CompletableFuture

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    simulateDelay(300);
    return new Product("p2", "Desktop", new BigDecimal("1999.99"), "Electronics", true);
})
.thenCompose(product -> CompletableFuture.supplyAsync(() -> {
    simulateDelay(200);
    return "Reviews for %s: ★★★★☆".formatted(product.name());
}));
// type: CompletableFuture<String> ✓ — the inner future is unwrapped

thenCompose is for when the next step is itself async. Your lambda takes a T and returns a CompletableFuture<R>. thenCompose unwraps that inner future and returns a flat CompletableFuture<R> — not a nested CompletableFuture<CompletableFuture<R>>. Think of it as .flatMap().

What happens when you use thenApply with an async callback

// WRONG — accidentally used thenApply instead of thenCompose
CompletableFuture<Object> future = CompletableFuture.supplyAsync(() -> {
    simulateDelay(300);
    return new Product("p2", "Desktop", new BigDecimal("1999.99"), "Electronics", true);
})
.thenApply(product -> CompletableFuture.supplyAsync(() -> {  // returns a Future, not a String
    simulateDelay(200);
    return "Reviews for %s: ★★★★☆".formatted(product.name());
}));

System.out.println(future.join());
// Output: java.util.concurrent.CompletableFuture@1fb3ebeb[Not completed]

The outer future completes immediately — its value is the inner CompletableFuture object, which hasn't run yet. When you call .join() and print the result, Java calls .toString() on that inner future. Nobody waits for it. You get the object reference, not the string you wanted.

The actual type here is CompletableFuture<CompletableFuture<String>>. The nested [Not completed] in the output is the tell.

thenApplythenCompose
Callback returnsR (a value)CompletableFuture<R> (a new async step)
Result typeCompletableFuture<R>CompletableFuture<R> (unwrapped)
C# analogy.ContinueWith(t => t.Result.Name)await (await FetchProductAsync()).FetchReviewsAsync()
Stream analogymapflatMap
Use whentransforming the result synchronouslythe next step is itself async

2. thenCompose vs thenCombine

Both deal with chaining multiple futures, but they answer different questions.

thenCompose — second operation needs the result of the first (dependent)

// Step 2 can't start until step 1 finishes — we need the product to fetch its reviews
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    simulateDelay(300);
    return new Product("p2", "Desktop", new BigDecimal("1999.99"), "Electronics", true);
})
.thenCompose(product -> CompletableFuture.supplyAsync(() -> {
    simulateDelay(200);
    return "Reviews for %s: ★★★★☆".formatted(product.name());
}));
// Total time: 300ms + 200ms = 500ms (sequential by necessity)

The two operations run sequentially because they have to. The review fetch needs the product object.

thenCombine — two independent operations running in parallel, results merged at the end

// Product details and live price have no dependency on each other — run them simultaneously
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    simulateDelay(300);
    System.out.println("Fetching product details...");
    return new Product("p2", "Desktop", new BigDecimal("1999.99"), "Electronics", true);
})
.thenCombine(
    CompletableFuture.supplyAsync(() -> {
        simulateDelay(200);
        System.out.println("Fetching live price...");
        return new BigDecimal("1799.99");
    }),
    (product, price) -> "%s costs £%s".formatted(product.name(), price)
);
// Total time: max(300ms, 200ms) = 300ms (parallel)

Both futures start at the same time. thenCombine waits for both to finish, then calls your merge function with both results. No dependency between them — no reason to serialize them.

thenComposethenCombine
RelationshipStep 2 depends on step 1's resultTwo independent operations
ExecutionSequentialParallel
Total latencySum of bothMax of both
C# analogyvar r = await FetchReviewsAsync(await FetchProductAsync())await Task.WhenAll(t1, t2) then combine t1.Result and t2.Result
Use whenYou need the first result to start the secondTwo calls have no dependency

3. allOf vs anyOf

Both take multiple futures. What they wait for is completely different.

allOf — wait for every future to complete

CompletableFuture<Product> t1 = CompletableFuture.supplyAsync(() -> {
    simulateDelay(300);
    return new Product("p1", "Laptop", new BigDecimal("999.99"), "Electronics", true);
});
CompletableFuture<Product> t2 = CompletableFuture.supplyAsync(() -> {
    simulateDelay(200);
    return new Product("p2", "Desktop", new BigDecimal("1999.99"), "Electronics", true);
});
CompletableFuture<Product> t3 = CompletableFuture.supplyAsync(() -> {
    simulateDelay(400);
    return new Product("p3", "Tablet", new BigDecimal("499.99"), "Electronics", true);
});

CompletableFuture<Void> all = CompletableFuture.allOf(t1, t2, t3);
all.join();  // waits for all three — 400ms total

System.out.println("- " + t1.join().name());  // safe — already done
System.out.println("- " + t2.join().name());
System.out.println("- " + t3.join().name());

allOf returns CompletableFuture<Void> — note the Void. It tells you when everything finished, but it does not hand you the results. You have to call .join() on each individual future yourself afterwards. Since allOf already waited, those .join() calls are instant — no thread blocking.

anyOf — take the first future that completes, ignore the rest

CompletableFuture<String> t1 = CompletableFuture.supplyAsync(() -> {
    simulateDelay(300);
    return "Result from source 1";
});
CompletableFuture<String> t2 = CompletableFuture.supplyAsync(() -> {
    simulateDelay(200);
    return "Result from source 2";  // fastest — wins
});
CompletableFuture<String> t3 = CompletableFuture.supplyAsync(() -> {
    simulateDelay(400);
    return "Result from source 3";
});

CompletableFuture<Object> any = CompletableFuture.anyOf(t1, t2, t3);
System.out.println(any.join());
// Output: Result from source 2

anyOf returns CompletableFuture<Object> — the type is erased because the futures don't have to be the same type. As soon as any one of them completes, anyOf completes with that result. The other futures keep running in the background — there is no automatic cancellation.

The classic use case: query multiple data sources or regional endpoints in parallel and use whichever responds first.

allOfanyOf
Completes whenEvery future is doneThe first future finishes
Return typeCompletableFuture<Void>CompletableFuture<Object>
ResultsRetrieved individually from each future after joinDirectly from .join()
C# analogyawait Task.WhenAll(t1, t2, t3)var winner = await Task.WhenAny(t1, t2, t3)
Use whenYou need all results before proceedingYou want the fastest response from N sources

4. exceptionally vs handle

Both let you respond to a failed future. The difference is whether you also see successful results.

exceptionally — runs only on failure, acts as a fallback

CompletableFuture<Product> future = CompletableFuture.supplyAsync(() -> {
    simulateDelay(300);
    if (Math.random() < 0.5) {
        throw new RuntimeException("Simulated failure fetching product");
    }
    return new Product("p3", "Tablet", new BigDecimal("499.99"), "Electronics", true);
})
.exceptionally(ex -> {
    System.out.println("Error: " + ex.getMessage());
    return new Product("default", "Default Product", BigDecimal.ZERO, "Unknown", false);
});

System.out.println("Result: " + future.join().name());

exceptionally is the recovery lane. If the upstream future succeeds, it is skipped entirely. If it fails, your function receives the exception and must return a fallback value of the same type. Think of it as a catch block that produces a default.

handle — runs on both success and failure

CompletableFuture<Product> future = CompletableFuture
    .supplyAsync(() -> new Product("p5", "Phone", new BigDecimal("699.00"), "Electronics", true))
    .handle((product, ex) -> {
        if (ex != null) {
            return new Product("fallback", "Error", BigDecimal.ZERO, "N/A", false);
        }
        return product;  // success path — product is non-null, ex is null
    });

handle always runs — success or failure. Your function receives both the result and the exception, and exactly one of them will be non-null. This makes it the right tool when you want to transform or log the result regardless of whether the upstream succeeded, or when you want a single place to handle both paths.

One important detail: unlike exceptionally, handle can also change the success result. You are not limited to returning a fallback — you can map the successful value at the same time.

exceptionallyhandle
Runs whenFailure onlyAlways (success and failure)
ReceivesThrowable ex(T result, Throwable ex) — one is always null
Can transform success?NoYes
C# analogycatch { return fallback; }try { ... } catch { ... } with shared return
Use whenSimple fallback on failureLogging both paths, or transforming success and failure in one place

5. get() vs join()

Both block the current thread until the future completes and return the result. The difference is purely about exception handling.

// get() — from Future<T>, throws checked exceptions
try {
    Product p = future.get();
} catch (InterruptedException | ExecutionException e) {
    // you are forced to handle these
}

// join() — from CompletableFuture<T>, wraps in unchecked CompletionException
Product p = future.join();  // no try-catch required by the compiler

get() is inherited from the Future<T> interface. It forces you to handle InterruptedException (thread was interrupted while waiting) and ExecutionException (the async task threw an exception — unwrap with ex.getCause()).

join() is CompletableFuture-specific. If the future failed, it wraps the cause in an unchecked CompletionException. If the thread is interrupted, it throws CancellationException rather than InterruptedException.

In practice: use join() in most production code — it produces cleaner call sites. Use get() when you specifically want the InterruptedException to be checked, typically in lower-level concurrent utilities where you need to respond to thread interruption explicitly.

get()join()
Exception typeInterruptedException, ExecutionException (checked)CompletionException (unchecked)
Compiler forces try-catch?YesNo
Unwrapping failuresex.getCause()ex.getCause() on CompletionException
C# analogytask.Result (blocks, throws AggregateException)Same, but cleaner — no analogue distinction in C#
Use whenYou need checked exception handling at the call siteGeneral use — less boilerplate

6. Cancellation: C#'s CancellationToken in Java

C# has a first-class cancellation primitive baked into the language:

var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

Task.Run(() => {
    for (int i = 0; i < 10; i++) {
        token.ThrowIfCancellationRequested();  // cooperative check
        Thread.Sleep(100);
    }
    return FetchProduct("p1");
}, token);

cts.Cancel();  // signal cancellation from outside

CompletableFuture has no equivalent type. There is no CancellationToken you pass around. But the same patterns are achievable — they are just less automatic.

Pattern 1: future.cancel(true) — cancelling the future itself

CompletableFuture<Product> future = CompletableFuture.supplyAsync(() -> {
    simulateDelay(1000);
    return new Product("p1", "Laptop", new BigDecimal("999.99"), "Electronics", true);
});

future.cancel(true);  // cts.Cancel() equivalent at the Future level

System.out.println(future.isCancelled());  // true
future.join();  // throws CancellationException

The important caveat: cancel(true) marks the CompletableFuture as cancelled and prevents any downstream .thenApply / .thenCompose chains from running. But the supplyAsync lambda that is already running in the ForkJoinPool thread is not interrupted — it keeps running to completion, its result is just discarded.

The true argument (mayInterruptIfRunning) is a holdover from Future<T>. With CompletableFuture, it is effectively ignored for supplyAsync tasks.

Pattern 2: Cooperative cancellation with AtomicBoolean — the real CancellationToken

This is the direct equivalent of C#'s cooperative model:

// C#:  var cts = new CancellationTokenSource();
//      CancellationToken token = cts.Token;
AtomicBoolean cancelled = new AtomicBoolean(false);  // the "CancellationToken"

CompletableFuture<Product> future = CompletableFuture.supplyAsync(() -> {
    for (int i = 1; i <= 10; i++) {
        if (cancelled.get()) {                        // token.ThrowIfCancellationRequested()
            System.out.println("  [worker] cancellation detected at step " + i + " — stopping");
            throw new CancellationException("Task cancelled by caller");
        }
        simulateDelay(100);
        System.out.println("  [worker] step " + i + " complete");
    }
    return new Product("p1", "Laptop", new BigDecimal("999.99"), "Electronics", true);
});

simulateDelay(350);               // steps 1, 2, 3 finish in ~300ms
System.out.println("[main] signalling cancellation...");
cancelled.set(true);              // cts.Cancel()

String result = future
    .thenApply(p -> "Completed: " + p.name())
    .exceptionally(ex -> "Cancelled — reason: " + ex.getCause().getMessage())
    .join();

System.out.println("[main] outcome: " + result);

Expected output:

  [worker] step 1 complete
  [worker] step 2 complete
  [worker] step 3 complete
[main] signalling cancellation...
  [worker] cancellation detected at step 4 — stopping
[main] outcome: Cancelled — reason: Task cancelled by caller

The AtomicBoolean is your CancellationToken. Any number of tasks can share a reference to the same flag — cancel it once and all of them stop at their next check. That is exactly how linked CancellationTokenSource works in C#.

Pattern 3: orTimeoutCancellationTokenSource(TimeSpan) equivalent

C# lets you create a token that cancels itself after a delay:

var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));

Java 9+ has orTimeout:

CompletableFuture<Product> future = CompletableFuture.supplyAsync(() -> {
    simulateDelay(1000);  // takes 1s
    return new Product("p1", "Laptop", new BigDecimal("999.99"), "Electronics", true);
})
.orTimeout(500, TimeUnit.MILLISECONDS);  // deadline: 500ms

future.exceptionally(ex -> {
    System.out.println(ex.getClass().getSimpleName());  // TimeoutException
    return new Product("none", "Timed out", BigDecimal.ZERO, "N/A", false);
}).join();

If the future does not complete within 500ms, it completes exceptionally with a TimeoutException. The underlying thread is still running — same caveat as cancel(true).

If you want a default value instead of an exception, use completeOnTimeout:

CompletableFuture<Product> future = CompletableFuture.supplyAsync(() -> {
    simulateDelay(1000);
    return new Product("p1", "Laptop", new BigDecimal("999.99"), "Electronics", true);
})
.completeOnTimeout(
    new Product("cached", "Cached Result", new BigDecimal("999.99"), "Electronics", true),
    500, TimeUnit.MILLISECONDS
);

System.out.println(future.join().name());  // "Cached Result" — no exception thrown
C# conceptJava equivalentStops the thread?
cts.Cancel() on the futurefuture.cancel(true)No — discards result only
token.ThrowIfCancellationRequested()if (cancelled.get()) throw new CancellationException()Yes — cooperative
CancellationTokenSource(TimeSpan)future.orTimeout(ms, MILLISECONDS)No — throws TimeoutException
Timeout with fallbackfuture.completeOnTimeout(defaultVal, ms, MILLISECONDS)No — provides default instead
Linked tokensShared AtomicBoolean reference across multiple lambdasYes — cooperative

The gap between C# and Java here is real. C# tokens are composable, propagate automatically through the call stack, and integrate with async/await natively. In Java, you wire the AtomicBoolean manually. It is more work, but the behaviour is identical — both rely on the running code choosing to check and stop.


The Pattern Underneath

All the distinctions follow the same shape: two methods (or approaches) that solve the same surface problem but differ in one specific dimension.

PairThe dimension that differs
thenApply vs thenComposeSync transform vs async chain (wrapping vs flattening)
thenCompose vs thenCombineDependent (sequential) vs independent (parallel)
allOf vs anyOfAll complete vs first completes
exceptionally vs handleFailure only vs both paths
get vs joinChecked vs unchecked exceptions
cancel(true) vs AtomicBooleanDiscard result vs cooperative stop

When you pick the wrong method, the compiler often won't stop you — the types still work out, or you accept a looser type like Object. The bug only shows up at runtime: a [Not completed] in the output, a future result that's never read, or an exception that's swallowed silently.

Knowing the dimension each pair differs on is the thing that makes the choice automatic.


Further Reading

Is AtomicBoolean actually the right tool for long-running tasks?

AtomicBoolean is a recognized pattern — but it has a critical limitation that matters the moment you apply it to real production work like database calls.

It only works for CPU-bound loops where your code is actively running and can check the flag between iterations:

for (Page page : pages) {
    if (cancelled.get()) break;   // checks between pages — works fine
    process(page);
}

For a live database call, the thread is blocked inside the JDBC driver waiting for the network response. It is not running your code, so it never reaches cancelled.get(). Setting the flag does nothing until the query returns on its own.

// cancelled.set(true) here does NOTHING until the query finishes
ResultSet rs = statement.executeQuery("SELECT * FROM products");  // blocked here

For true mid-flight cancellation of a JDBC call, you need Statement.cancel(), which sends a cancel signal to the database server:

AtomicReference<Statement> activeStmt = new AtomicReference<>();

CompletableFuture<List<Product>> future = CompletableFuture.supplyAsync(() -> {
    try (Statement stmt = conn.createStatement()) {
        activeStmt.set(stmt);
        ResultSet rs = stmt.executeQuery("SELECT * FROM products");
        // ... process results
    }
});

// On client disconnect — actually stops the in-flight query:
Statement s = activeStmt.get();
if (s != null) s.cancel();
future.cancel(true);

Thread interruption: the more idiomatic Java approach

Java's built-in cooperative cancellation is Thread.interrupt(), not an AtomicBoolean. Most blocking operations — Thread.sleep, Object.wait, NIO channels — throw InterruptedException when the thread is interrupted. This means interruption propagates naturally through blocking code without any manual flag checks:

// Most blocking calls already respect this — no AtomicBoolean needed
if (Thread.currentThread().isInterrupted()) {
    throw new InterruptedException("Cancelled");
}

future.cancel(true) does interrupt the underlying thread for a raw FutureTask, but not for CompletableFuture.supplyAsync — the ForkJoinPool task is already submitted and the interrupt is ignored. This is why AtomicBoolean exists as a workaround in CompletableFuture-based code.

The real-world cancellation map

ScenarioIdiomatic Java approach
CPU-bound loopAtomicBoolean check, or Thread.interrupted()
JDBC / databaseStatement.cancel() — sends cancel to the DB server
HTTP client callTimeout via HttpRequest.Builder.timeout(), or close the client
Spring MVC asyncDeferredResult.onTimeout / future.orTimeout(...)
Spring WebFluxCancellation propagates automatically via subscription disposal
Java 21 virtual threadsStructuredTaskScope.ShutdownOnFailure — cancels sibling tasks automatically
Thread-pool tasksThread.interrupt() — respected by most blocking I/O

The thumb rule

  • AtomicBoolean → loops between discrete checkpoints, or when combining with CompletableFuture where thread interruption doesn't reach
  • Statement.cancel() → the only way to actually stop a running DB query mid-flight
  • Reactive (R2DBC, WebFlux) → cancellation is structural, no manual wiring needed
  • Java 21 StructuredTaskScope → the modern answer: cancel is automatic when the scope shuts down

AtomicBoolean is worth knowing and teaching precisely because it makes the cooperative model explicit. In production, you combine it with resource-level cancellation (Statement.cancel) or move to a framework that handles it structurally.


Peace... 🍀