Writing6 min read
Cover illustration for the Java CompletableFuture foundation article
Software EngineeringFebruary 1, 2026

Java CompletableFuture, Part 1: The Foundation

A ground-up look at why CompletableFuture exists, how it evolved from raw threads and Future, and the mental model that makes async code readable instead of fragile.

javaasyncconcurrencycompletablefuturethreading

The Problem: Why Blocking I/O Kills Your Application

Picture this: your Spring Boot service handles 100 requests per second. Each request fetches user data from a database — a 100ms operation. With a typical thread pool of 200 threads, you are already at capacity. One slow query, and your entire service starts queuing requests.

This is the classic thread-per-request bottleneck. Your threads spend 90% of their time waiting — waiting for the database, waiting for an HTTP response, waiting for a file to load. They are not computing anything; they are just blocked.

CompletableFuture exists to solve this problem.

The Evolution: From Thread to CompletableFuture

Java's concurrency model evolved in stages. Understanding that history makes it easier to know which tool to reach for.

Level 1: Raw Threads (Java 1.0)

java
Thread thread = new Thread(() -> {
    User user = database.fetchUser(userId);
    System.out.println(user);
});
thread.start();
// No way to return a value from here

No return value. No composition. Manual thread management at every callsite.

Level 2: ExecutorService + Future (Java 5)

java
ExecutorService executor = Executors.newFixedThreadPool(10);
Future<User> future = executor.submit(() -> database.fetchUser(userId));
 
User user = future.get(); // Blocks until done

Future.get() blocks the calling thread. You cannot chain operations or combine futures without writing your own coordination logic.

Level 3: CompletableFuture (Java 8)

java
CompletableFuture<User> future = CompletableFuture.supplyAsync(
    () -> database.fetchUser(userId)
);
 
future.thenApply(user -> user.getEmail())
      .thenAccept(email -> sendWelcomeEmail(email));
// Thread is free to do other work

Callbacks, composition, and non-blocking operations all become first-class.

Level 4: @Async (Spring)

java
@Async
public CompletableFuture<User> fetchUser(Long userId) {
    return CompletableFuture.completedFuture(database.fetchUser(userId));
}

Convenient on the surface, but it carries subtle pitfalls. Manual CompletableFuture is usually the better choice over @Async — a tradeoff worth examining on its own.

The Mental Model: Think "Promise", Not "Thread"

If you have worked with JavaScript, CompletableFuture is Java's Promise. It represents a value that will be available in the future.

plaintext
CompletableFuture<User> = "I promise to give you a User... eventually."

The key insight: you do not wait for the promise to resolve. Instead, you tell it what to do when the value arrives:

java
fetchUserAsync(userId)
    .thenApply(user -> user.getEmail())      // When you have the user, extract email
    .thenAccept(email -> log.info(email));   // When you have the email, log it

The thread that calls this code returns immediately. It does not block. It does not wait. It is free to handle other requests.

supplyAsync vs runAsync

There are two ways to start an async operation.

supplyAsync — When you need a result

java
// Returns CompletableFuture<User>
CompletableFuture<User> future = CompletableFuture.supplyAsync(() -> {
    return database.fetchUser(userId);
});

runAsync — Fire-and-forget

java
// Returns CompletableFuture<Void>
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    auditLog.record("User viewed page");
});

Use supplyAsync in the vast majority of cases. Fire-and-forget situations where you genuinely do not need the result are rare.

The Cardinal Sin: Blocking Your Async Code

The most common mistake in production code:

java
// Bad: defeats the purpose of async
public User getUser(Long userId) {
    CompletableFuture<User> future = fetchUserAsync(userId);
    return future.get();  // Blocks — thread sits idle
}

You have created an async operation, then immediately blocked waiting for it. You might as well have called the database synchronously. In fact, it is worse: you added overhead with no benefit.

Return the future instead

java
// Good: let the caller decide
public CompletableFuture<User> getUser(Long userId) {
    return fetchUserAsync(userId);
}

Now the caller can chain operations without blocking:

java
userService.getUser(userId)
    .thenApply(this::enrichProfile)
    .thenAccept(this::cacheResult);
// Thread returns immediately

Transforming Results: thenApply and thenAccept

Once you have a CompletableFuture, you will typically want to transform or consume its result.

thenApply — Transform the value

Analogous to Stream.map. Takes a value and returns a new value.

java
CompletableFuture<String> emailFuture = fetchUserAsync(userId)
    .thenApply(user -> user.getEmail());  // User -> String

thenAccept — Consume the value

For side effects where no return value is needed.

java
fetchUserAsync(userId)
    .thenAccept(user -> {
        log.info("Fetched user: {}", user.getUsername());
        metrics.increment("user.fetch.success");
    });

Chaining both together

java
fetchUserAsync(userId)
    .thenApply(user -> user.getEmail())           // User -> String
    .thenApply(email -> email.toUpperCase())      // String -> String
    .thenAccept(email -> sendEmail(email));       // Consume

Each step is non-blocking. The thread that starts this chain returns immediately.

completedFuture: Returning Immediate Values

Sometimes you already have the value — it is cached, or validation failed early. You do not need an async operation, but your API signature returns CompletableFuture.

java
public CompletableFuture<User> fetchUser(Long userId, User cachedUser) {
    if (cachedUser != null) {
        return CompletableFuture.completedFuture(cachedUser);
    }
    return fetchUserAsync(userId);
}

completedFuture also makes testing significantly cleaner. You can stub async methods without spinning up real async infrastructure.

join() vs get(): When You Must Block

Blocking is sometimes unavoidable: at the edge of a synchronous controller, in tests, or in a main() method. When you must block, prefer join() over get().

java
// get() forces checked exception handling
try {
    User user = future.get();
} catch (InterruptedException | ExecutionException e) {
    // Required boilerplate
}
 
// join() throws unchecked CompletionException — cleaner
User user = future.join();

Every join() or get() call is a prompt to ask: could this return the future to its caller instead?

When Not to Use CompletableFuture

CompletableFuture is not the right tool for every situation.

CPU-bound operations

java
// Bad: CompletableFuture adds no value for pure CPU work
CompletableFuture.supplyAsync(() -> fibonacci(1000000));
 
// Better: parallel streams
list.parallelStream().map(this::compute).collect(toList());

CompletableFuture is built for I/O-bound work (waiting). For CPU-bound work (computing), parallel streams are the better fit.

Simple synchronous operations

java
// Over-engineered
CompletableFuture.supplyAsync(() -> user.getName().toUpperCase());
 
// Just do it directly
user.getName().toUpperCase();

If an operation takes microseconds, the async overhead is not worth it.

Operations requiring strict sequential ordering

If operations must happen in sequence with no parallelism benefit, async adds complexity without improving throughput.

Quick Reference

  • supplyAsync(supplier) — start an async computation that produces a value; returns CompletableFuture<T>
  • runAsync(runnable) — fire-and-forget with no return value; returns CompletableFuture<Void>
  • thenApply(fn) — transform a value (like map); returns CompletableFuture<U>
  • thenAccept(consumer) — consume a value for side effects; returns CompletableFuture<Void>
  • completedFuture(value) — wrap a cached or already-computed value; returns CompletableFuture<T>
  • join() — block and get the result, throws unchecked CompletionException; returns T

Key Takeaways

  1. CompletableFuture solves thread blocking: threads can do other work while waiting for I/O.
  2. Never call get() or join() immediately after creating a future. Return the future and let callers chain operations.
  3. Think in callbacks: "when this completes, do that" instead of "wait for this, then do that."
  4. Use supplyAsync when you need a result. Use runAsync for fire-and-forget operations.
  5. completedFuture is the right tool for caching, early returns, and test stubs.

Up Next: Composition

The real power of CompletableFuture is composition: combining multiple async operations in a way that is both readable and efficient. Part 2 covers the difference between thenApply and thenCompose, running operations in parallel with allOf and thenCombine, building a real-world dashboard that loads user, orders, and payments concurrently, and why ForkJoinPool.commonPool() is not safe for production use.