Chaturmind
LearnDSASystem DesignDevOpsEngineering 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

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

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.

Previous

volatile and the Memory Model

Next

Deadlock, Starvation, Livelock

AI Tutor

Lesson: CompletableFuture

Quick actions

AI responses can be inaccurate. Verify critical information.