Java Concurrency
Future vs CompletableFuture
Future represents a result that may be available later. CompletableFuture adds async composition: chaining, combining, transforming, and handling errors without immediately blocking.
The Short Answer
A Future represents a result that may be available later.
A CompletableFuture also represents a result that may be available later, but it lets you chain, combine, transform, and handle async results much more naturally.
CompletableFuture lets you say: “When the result is ready, do the next thing.”
The Real Problem
Imagine a backend service calling another service in the background. You submit the task and get back a Future.
That sounds good, but the awkward part is what happens next. With a plain Future, you usually call get(), and that blocks the current thread until the result is ready.
Future
CompletableFuture
Future Example
Future is commonly returned by ExecutorService when you submit a Callable.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class FutureExample {
public static void main(String[] args) throws Exception {
ExecutorService executor =
Executors.newFixedThreadPool(2);
Future<String> future =
executor.submit(() -> {
Thread.sleep(1_000);
return "payment approved";
});
System.out.println("Doing other work...");
String result = future.get(); // blocks here
System.out.println(result);
executor.shutdown();
}
}This works, but the blocking call is the limitation. Once you call get(), the current thread waits.
Future Mental Model
But when you want the actual result, you usually have to stand at the counter and wait.
Future gives you basic methods:
- isDone()
- isCancelled()
- cancel()
- get()
- get(timeout, unit)
Useful, but limited.
CompletableFuture Example
CompletableFuture lets you attach behavior that runs when the result is ready.
import java.util.concurrent.CompletableFuture;
public class CompletableFutureExample {
public static void main(String[] args) {
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> {
sleep(1_000);
return "payment approved";
});
CompletableFuture<String> message =
future.thenApply(result ->
"Result: " + result
);
System.out.println(message.join());
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}The key difference is that thenApply() describes the next transformation instead of forcing you to block immediately.
thenApply vs thenCompose
These two methods are easy to confuse.
thenApply
Use when you have a normal synchronous transformation from one value to another.
thenCompose
Use when the next step itself returns another CompletableFuture.
CompletableFuture<String> userFuture =
CompletableFuture.supplyAsync(() -> "user-123");
CompletableFuture<String> displayName =
userFuture.thenApply(userId ->
"Display name for " + userId
);CompletableFuture<String> userFuture =
CompletableFuture.supplyAsync(() -> "user-123");
CompletableFuture<String> profileFuture =
userFuture.thenCompose(userId ->
fetchProfileAsync(userId)
);
static CompletableFuture<String> fetchProfileAsync(String userId) {
return CompletableFuture.supplyAsync(() ->
"Profile for " + userId
);
}thenCompose chains another async operation.
Combining Two Async Results
CompletableFuture is especially useful when two async tasks can run in parallel and then be combined.
import java.util.concurrent.CompletableFuture;
public class CombineFuturesExample {
public static void main(String[] args) {
CompletableFuture<Integer> priceFuture =
CompletableFuture.supplyAsync(() -> 100);
CompletableFuture<Integer> taxFuture =
CompletableFuture.supplyAsync(() -> 8);
CompletableFuture<Integer> totalFuture =
priceFuture.thenCombine(
taxFuture,
(price, tax) -> price + tax
);
System.out.println(totalFuture.join()); // 108
}
}This is much cleaner than manually blocking on two Future objects and then combining the results yourself.
Handling Errors
CompletableFuture also gives you a more fluent way to handle failures.
import java.util.concurrent.CompletableFuture;
public class CompletableFutureErrorExample {
public static void main(String[] args) {
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("payment service failed");
});
String result = future
.exceptionally(ex -> "fallback response")
.join();
System.out.println(result);
}
}Instead of catching the exception only around a blocking get(), you can attach error handling to the async pipeline.
Why CompletableFuture Is A Bigger Leap Than It First Appears
When developers first learn CompletableFuture, it often feels like a small improvement over Future.
Future<String> future =
executor.submit(...);
String result = future.get();CompletableFuture<String> future =
CompletableFuture.supplyAsync(...);
String result = future.join();At first glance these look very similar. In both cases, you eventually wait for the result. Yes, CompletableFuture allows composition of multiple tasks, but then you have to wait at the end anyway, right?
The key insight is that CompletableFuture often lets you avoid waiting immediately.
"Is the result ready yet?"
CompletableFuture encourages you to say:
"When the result is ready, do this next."
Instead of blocking right away, you can describe an entire pipeline of work:
Fetch User
↓
Fetch Orders
↓
Combine Results
↓
Build Dashboard
↓
Return ResponseThis becomes especially powerful when multiple independent operations can run in parallel and then be combined later.
That is why CompletableFuture is often described as an async workflow or async composition framework rather than simply a better Future.
CompletableFuture Can Represent Work That Hasn't Happened Yet
Another capability that is not obvious at first is that a CompletableFuture does not always have to be tied directly to a running thread.
You can create an empty CompletableFuture and complete it later when some external event occurs.
CompletableFuture<String> future =
new CompletableFuture<>();
// The computation is finished.
// The result is "done".
future.complete("done");
System.out.println(future.join()); // doneThe value passed to complete() becomes the result that anyone waiting on the CompletableFuture receives.
A regular Future cannot do this.
A CompletableFuture can represent work, an event, or a result that may arrive sometime in the future.
Things become more interesting when callbacks are attached before the result is available.
CompletableFuture<String> future =
new CompletableFuture<>();
future.thenAccept(value ->
System.out.println(
"Received: " + value
)
);
// Some external event occurs later...
future.complete("payment approved");Received: payment approvedHere, thenAccept() registers code that should run when the result becomes available. Nothing happens immediately. When complete() is eventually called, the callback executes and receives the supplied value.
This turns out to be useful when integrating with systems that are not simple thread-based tasks.
Message Queues
Complete the future when a Kafka message or queue response arrives.
Callbacks
Complete the future when an external callback is triggered.
Event Systems
Complete the future when an application event occurs.
WebSockets
Complete the future when a message arrives from a client or server.
Most Spring Boot applications only use a small subset of CompletableFuture's capabilities. However, this ability to represent results that have not happened yet is one of the reasons the class is much more powerful than Future.
Future = "I started some work. Tell me when it finishes."
CompletableFuture = "Something will eventually produce a result. In the meantime, let me describe what should happen next."
Future vs CompletableFuture Comparison
| Feature | Future | CompletableFuture |
|---|---|---|
| Represents async result | Yes | Yes |
| Usually blocks to get result | Yes | Not necessarily |
| Chain transformations | No | Yes |
| Combine async results | Manual | Built-in |
| Error handling pipeline | Limited | Better |
| Can be manually completed | No | Yes |
join() vs get()
Both can retrieve the final value, but they handle exceptions differently.
get()
Throws checked exceptions: InterruptedException and ExecutionException.
join()
Throws unchecked CompletionException if the computation failed.
You will often see join() in examples because it is less noisy for demo code.
Common CompletableFuture Methods
supplyAsync
Run async work that returns a value.
runAsync
Run async work that does not return a value.
thenApply
Transform a result.
thenCompose
Chain another async operation.
thenCombine
Combine two independent async results.
exceptionally
Recover from a failure with a fallback value.
When Would You Use Which?
Use Future When
You only need to submit a task and later block to retrieve the result.
Use CompletableFuture When
You need async chaining, combining, transformation, fallback, or non-blocking style workflows.
Interview-Friendly Explanation
Common Interview Follow-Ups
Is CompletableFuture a Future?
Yes. CompletableFuture implements Future and CompletionStage.
Why is Future limited?
Future can represent a pending result, but it does not naturally support chaining, combining, or callback-style transformations. You usually call get(), which blocks.
What is the difference between thenApply and thenCompose?
thenApply is for transforming a value. thenCompose is for chaining another async operation that returns a CompletableFuture.
What is the difference between join and get?
get throws checked exceptions. join throws unchecked CompletionException when the computation fails.
Does CompletableFuture always create a new thread?
No. Async methods run using an Executor. If you do not provide one, common async methods typically use the common ForkJoinPool.