Chaturmind
LearnDSASystem DesignInterview PrepDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth
  • Java Interview Prep

Company

  • About
  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

👥10,106visitors

Built for engineers who want to go deep.


← Java Concurrency & Multithreading

Threads & Runnable

  • Introduction to Threads
  • ExecutorService & Thread Pools
  • Fork/Join Framework

Synchronization & Memory Model

  • synchronized and Locks
  • volatile and the Memory Model
  • CompletableFuture
  • Deadlock, Starvation, Livelock

Concurrent Collections

  • ConcurrentHashMap & CopyOnWriteArrayList
  • HashMap Concurrency Variants
  • Concurrent Utilities & Coordination
Chaturmind
← Java Concurrency & Multithreading

Threads & Runnable

  • Introduction to Threads
  • ExecutorService & Thread Pools
  • Fork/Join Framework

Synchronization & Memory Model

  • synchronized and Locks
  • volatile and the Memory Model
  • CompletableFuture
  • Deadlock, Starvation, Livelock

Concurrent Collections

  • ConcurrentHashMap & CopyOnWriteArrayList
  • HashMap Concurrency Variants
  • Concurrent Utilities & Coordination
HomeLearnJavaJava Concurrency & MultithreadingSynchronization & Memory Model
✓ FreeAdvanced· 15 min read

CompletableFuture

Compose async operations without callbacks — thenApply, thenCompose, allOf, anyOf.

Published September 21, 2026


CompletableFuture

CompletableFuture<T> (Java 8+) is a composable async computation. Unlike Future, it supports non-blocking callbacks, chaining, and combining multiple async operations.

Creating CompletableFutures

// Run async, no return value
CompletableFuture<Void> cf1 = CompletableFuture.runAsync(() -> {
    System.out.println("async task");
});

// Async with return value
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> {
    return fetchUserFromDB("u1"); // runs in ForkJoinPool.commonPool()
});

// With custom executor
ExecutorService pool = Executors.newFixedThreadPool(4);
CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(
    () -> fetchUser("u1"), pool
);

Chaining with thenApply, thenAccept, thenRun

CompletableFuture.supplyAsync(() -> "user_123")
    .thenApply(userId -> fetchUser(userId))      // transform: String → User
    .thenApply(user -> user.getEmail())           // transform: User → String
    .thenAccept(email -> sendEmail(email))        // consume: String → void
    .thenRun(() -> log.info("Email sent"))        // run after: void → void
    .exceptionally(e -> {
        log.error("Failed", e);
        return null;
    });

thenCompose — flatten nested futures

// thenApply wraps the result: CompletableFuture<CompletableFuture<User>>
// thenCompose flattens it:    CompletableFuture<User>

CompletableFuture<User> future = CompletableFuture
    .supplyAsync(() -> "user_123")
    .thenCompose(userId -> fetchUserAsync(userId)); // returns CF<User>

Combining Multiple Futures

// Wait for all to complete
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> fetchName());
CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> fetchAge());

CompletableFuture<String> combined = f1.thenCombine(f2,
    (name, age) -> name + " is " + age);

// Wait for a list of futures
List<CompletableFuture<String>> futures = userIds.stream()
    .map(id -> CompletableFuture.supplyAsync(() -> fetchUser(id)))
    .toList();

CompletableFuture<Void> allDone = CompletableFuture.allOf(
    futures.toArray(new CompletableFuture[0])
);

// Collect results after all complete
allDone.thenApply(v -> futures.stream()
    .map(CompletableFuture::join)
    .toList());

// Complete when the FIRST one finishes
CompletableFuture<Object> anyDone = CompletableFuture.anyOf(
    futures.toArray(new CompletableFuture[0])
);

Error Handling

CompletableFuture.supplyAsync(() -> riskyOperation())
    .exceptionally(ex -> {
        log.error("Operation failed", ex);
        return defaultValue(); // recovery value
    })
    .handle((result, ex) -> {
        // Called whether succeeded or failed
        if (ex != null) return handleError(ex);
        return transform(result);
    });

Real-World Pattern: Parallel API Calls

public UserProfileDto getProfile(String userId) {
    CompletableFuture<User> userFuture =
        CompletableFuture.supplyAsync(() -> userService.findById(userId));
    CompletableFuture<List<Order>> ordersFuture =
        CompletableFuture.supplyAsync(() -> orderService.findByUser(userId));
    CompletableFuture<UserStats> statsFuture =
        CompletableFuture.supplyAsync(() -> statsService.getStats(userId));

    // Wait for all three in parallel
    return CompletableFuture.allOf(userFuture, ordersFuture, statsFuture)
        .thenApply(v -> new UserProfileDto(
            userFuture.join(),
            ordersFuture.join(),
            statsFuture.join()
        ))
        .join(); // block for the final result
}

get() vs join() — the exception-handling difference

Both block and return the result. get() throws checked InterruptedException and ExecutionException, forcing a try/catch. join() throws an unchecked CompletionException wrapping the same underlying cause — which is exactly why join() is preferred inside stream pipelines and lambda bodies, where a checked exception can't propagate without extra boilerplate.

thenApply vs thenApplyAsync — which thread runs the continuation

thenApply runs its callback on whichever thread completed the previous stage (the calling thread, if the future was already done; the async task's own thread, otherwise) — it does not guarantee execution on a new thread. thenApplyAsync always submits the callback to an executor (the common pool by default, or an explicitly supplied one), guaranteeing it runs on a pool thread rather than possibly inline on the completing thread. This matters when the previous stage's thread holds a lock or thread-local state you don't want the continuation running under, or when the continuation itself is CPU-heavy and shouldn't block the completing thread.

whenComplete vs handle — can you replace the result?

Both run after a stage completes, success or failure, and both receive (result, exception). The difference: handle's return value becomes the new stage's result — it can transform a failure into a recovered value, or a success into something else entirely. whenComplete is a side-effecting callback only — it returns the same CompletableFuture unchanged, so it's for logging/cleanup, not recovery.

Timeout handling (Java 9+)

CompletableFuture<String> withTimeout = CompletableFuture
    .supplyAsync(() -> slowCall())
    .orTimeout(2, TimeUnit.SECONDS);              // completes exceptionally with TimeoutException if not done in time

CompletableFuture<String> withFallback = CompletableFuture
    .supplyAsync(() -> slowCall())
    .completeOnTimeout("default value", 2, TimeUnit.SECONDS); // completes with a fallback VALUE instead of an exception

orTimeout fails the future (an exceptional completion you still need to handle downstream); completeOnTimeout succeeds it with a supplied default, sidestepping exception handling entirely when a reasonable fallback exists.

Exception propagation across a long chain

An exception thrown in any stage short-circuits every subsequent thenApply/thenCompose/thenAccept stage — they're skipped entirely — until the chain reaches an exceptionally, handle, or whenComplete stage capable of observing it. This means a single .exceptionally() at the very end of a long chain can recover from a failure in any earlier stage, which is convenient but can also make it unclear which stage actually failed without additional logging — a common reason to place a .whenComplete((r, e) -> { if (e != null) log.error(...) }) stage immediately after the stage most likely to fail, localizing where the error is observed even though recovery might still happen later in the chain.

Interview Tips

  1. Know the difference between thenApply (synchronous transform in callback thread) and thenApplyAsync (transform in a new thread).
  2. join() is like get() but throws unchecked exceptions — preferred in streams.
  3. CompletableFuture.allOf() returns CompletableFuture<Void> — you must call join() on each individual future to get results.

Advertisement

Pitfalls that show up in production

  • The default executor. supplyAsync(task) without an executor runs on ForkJoinPool.commonPool(), which is shared by the whole JVM (parallel streams use it too) and sized to the number of CPU cores. Blocking I/O there (HTTP calls, JDBC) quickly starves it, and unrelated parts of the application slow down. Pass a dedicated executor for blocking work, or use virtual threads: supplyAsync(task, Executors.newVirtualThreadPerTaskExecutor()) (keep that executor as a shared field, not created per call).
  • Forgotten exceptions. If nobody calls join()/get(), or attaches exceptionally/handle/whenComplete, a failure in an async stage vanishes silently. Every chain should end in something that observes the outcome, even if it only logs.
  • Blocking inside the chain. Calling join() on another future inside a thenApply blocks a pool thread while it waits. Use thenCompose (dependent async step) or thenCombine/allOf (independent steps) instead.
  • Cancellation doesn't interrupt. future.cancel(true) completes the future exceptionally, but does not stop the running task. The work continues in the background. Pass a cancellation signal to long-running tasks yourself, or use structured concurrency.
  • Lost context. ThreadLocal-based context (the security context, MDC log IDs, transactions) doesn't automatically follow work onto other threads. Capture what you need before going async, or use context-propagating executors, such as Spring's TaskDecorator.

Collecting results from many futures

allOf returns CompletableFuture<Void>, so a small helper is useful for gathering values:

static <T> CompletableFuture<List<T>> allAsList(List<CompletableFuture<T>> futures) {
    return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
            .thenApply(v -> futures.stream().map(CompletableFuture::join).toList());   // join() won't block here
}

If any future fails, the combined future fails too. When partial results are acceptable (show the prices from the suppliers that answered), convert each failure to a fallback before combining: f.exceptionally(e -> Quote.unavailable()), together with orTimeout or completeOnTimeout per call.

Follow-up questions this topic invites — and their answers

Q: What's the difference between thenApply and thenCompose? A: thenApply transforms a result with a plain function (T → U). thenCompose is for a function that itself returns a future (T → CompletableFuture<U>), and it flattens the result, avoiding CompletableFuture<CompletableFuture<U>>. It's the async equivalent of map vs flatMap.

Q: Why shouldn't you run blocking calls on the common pool? A: The common pool has about one thread per CPU core, and it's shared JVM-wide. Blocking I/O occupies those few threads while waiting, so other async tasks and parallel streams can't run. Use a dedicated executor sized for I/O, or virtual threads.

Q: What's the difference between exceptionally, handle and whenComplete? A: exceptionally runs only on failure, and supplies a replacement value. handle runs on success or failure, and can transform either into a new result. whenComplete runs on both too, but can't change the result. It's for side effects such as logging or metrics.

Q: How do you add a timeout to a CompletableFuture? A: Since Java 9, orTimeout(duration, unit) fails the future with a TimeoutException if it isn't complete in time, and completeOnTimeout(value, duration, unit) completes it with a default value instead. Neither stops the underlying task, so also give the task a timeout, such as the HTTP client's.

Q: When would you choose CompletableFuture over virtual threads? A: CompletableFuture suits composing asynchronous APIs that already return futures, and fan-out/fan-in with combinators. With virtual threads, you can often write the same logic as plain sequential blocking code, which is easier to read and debug. Many teams now prefer virtual threads for request handling, and keep CompletableFuture for composition-heavy code.

Advertisement

Previous

volatile and the Memory Model

Next

Deadlock, Starvation, Livelock

AI Tutor

Lesson: CompletableFuture

Quick actions

AI responses can be inaccurate. Verify critical information.