
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.
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)
Thread thread = new Thread(() -> {
User user = database.fetchUser(userId);
System.out.println(user);
});
thread.start();
// No way to return a value from hereNo return value. No composition. Manual thread management at every callsite.
Level 2: ExecutorService + Future (Java 5)
ExecutorService executor = Executors.newFixedThreadPool(10);
Future<User> future = executor.submit(() -> database.fetchUser(userId));
User user = future.get(); // Blocks until doneFuture.get() blocks the calling thread. You cannot chain operations or combine futures without writing your own coordination logic.
Level 3: CompletableFuture (Java 8)
CompletableFuture<User> future = CompletableFuture.supplyAsync(
() -> database.fetchUser(userId)
);
future.thenApply(user -> user.getEmail())
.thenAccept(email -> sendWelcomeEmail(email));
// Thread is free to do other workCallbacks, composition, and non-blocking operations all become first-class.
Level 4: @Async (Spring)
@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.
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:
fetchUserAsync(userId)
.thenApply(user -> user.getEmail()) // When you have the user, extract email
.thenAccept(email -> log.info(email)); // When you have the email, log itThe 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
// Returns CompletableFuture<User>
CompletableFuture<User> future = CompletableFuture.supplyAsync(() -> {
return database.fetchUser(userId);
});runAsync — Fire-and-forget
// 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:
// 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
// Good: let the caller decide
public CompletableFuture<User> getUser(Long userId) {
return fetchUserAsync(userId);
}Now the caller can chain operations without blocking:
userService.getUser(userId)
.thenApply(this::enrichProfile)
.thenAccept(this::cacheResult);
// Thread returns immediatelyTransforming 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.
CompletableFuture<String> emailFuture = fetchUserAsync(userId)
.thenApply(user -> user.getEmail()); // User -> StringthenAccept — Consume the value
For side effects where no return value is needed.
fetchUserAsync(userId)
.thenAccept(user -> {
log.info("Fetched user: {}", user.getUsername());
metrics.increment("user.fetch.success");
});Chaining both together
fetchUserAsync(userId)
.thenApply(user -> user.getEmail()) // User -> String
.thenApply(email -> email.toUpperCase()) // String -> String
.thenAccept(email -> sendEmail(email)); // ConsumeEach 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.
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().
// 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
// 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
// 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; returnsCompletableFuture<T>runAsync(runnable)— fire-and-forget with no return value; returnsCompletableFuture<Void>thenApply(fn)— transform a value (likemap); returnsCompletableFuture<U>thenAccept(consumer)— consume a value for side effects; returnsCompletableFuture<Void>completedFuture(value)— wrap a cached or already-computed value; returnsCompletableFuture<T>join()— block and get the result, throws uncheckedCompletionException; returnsT
Key Takeaways
CompletableFuturesolves thread blocking: threads can do other work while waiting for I/O.- Never call
get()orjoin()immediately after creating a future. Return the future and let callers chain operations. - Think in callbacks: "when this completes, do that" instead of "wait for this, then do that."
- Use
supplyAsyncwhen you need a result. UserunAsyncfor fire-and-forget operations. completedFutureis 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.