In modern microservice architectures, asynchronous programming plays a critical role in building scalable, responsive, and resource-efficient systems. While Java allows you to create threads using the Thread class or Runnable interface, using CompletableFuture is often the preferred approach in microservices.
This tutorial explains why CompletableFuture is preferred over plain threads with in-depth comparisons, real-world use cases, benefits, and code examples.
1. Introduction
1.1. Plain Threads in Java
Java allows you to spawn new threads using:
Thread t = new Thread(() -> {
// do some work
});
t.start();
This is simple and low-level, but becomes hard to manage in complex, concurrent microservice environments.
1.2. CompletableFuture
Introduced in Java 8, CompletableFuture is a powerful asynchronous programming API in java.util.concurrent. It helps to chain, compose, and handle asynchronous tasks in a non-blocking, efficient, and functional manner.
2. Why Not Use Plain Threads in Microservices?
While using Thread is technically correct, it suffers from several drawbacks in the context of microservices:
2.1. High Resource Consumption
- Threads are heavyweight: each thread consumes ~1MB stack memory.
- Creating too many threads can exhaust system memory and CPU.
- Thread creation is expensive and doesn’t scale well under high load.
2.2. Poor Scalability
- Blocking threads (e.g., waiting for I/O) wastes system resources.
- Threads don’t scale to thousands of concurrent tasks.
2.3. Manual Management
- You must manage
Thread,Runnable, and thread lifecycle yourself. - Difficult to implement thread pools, timeouts, and error handling.
2.4. Harder Error Propagation
- Exception handling is messy and non-composable across threads.
3. Why CompletableFuture Is Preferred in Microservices
Here’s a breakdown of the key benefits that make CompletableFuture a better fit for microservices:
3.1. Asynchronous & Non-Blocking
CompletableFuture allows microservices to perform tasks asynchronously without blocking threads.
Example:
CompletableFuture.supplyAsync(() -> {
return callDownstreamService(); // runs in a thread pool
});
This is important in microservices, which often call remote APIs, databases, or message queues — all of which are I/O-bound and benefit from non-blocking handling.
3.2. Thread Pool Integration (ExecutorService)
CompletableFuture works with thread pools (ExecutorService), allowing you to reuse threads efficiently instead of creating new ones every time.
ExecutorService executor = Executors.newFixedThreadPool(10);
CompletableFuture.runAsync(() -> {
doWork();
}, executor);
Result: Reduced memory usage, better CPU utilization.
3.3. Composability
You can chain multiple asynchronous tasks using:
thenApply()– transforms resultthenCompose()– flatMap style chainingthenCombine()– combines results of two futures
CompletableFuture.supplyAsync(this::fetchUser)
.thenCompose(user -> fetchOrders(user))
.thenAccept(orders -> display(orders));
This enables clean and readable async code – crucial in microservice communication chains.
3.4. Better Error Handling
You can handle exceptions gracefully using methods like:
exceptionally()handle()whenComplete()
CompletableFuture.supplyAsync(this::callService)
.exceptionally(ex -> {
logError(ex);
return fallbackResponse();
});
This prevents thread crashes and improves microservice resilience.
3.5. Timeouts and Fallbacks
Microservices must respond fast and avoid hanging requests. CompletableFuture supports:
orTimeout(duration)– cancels long-running taskscompleteOnTimeout(value, duration)– fallback value if timeout
CompletableFuture.supplyAsync(this::slowService)
.completeOnTimeout("fallback", 1, TimeUnit.SECONDS);
This is much harder to implement with plain threads.
3.6. Reactive & Functional Style
CompletableFuture allows for a functional, declarative programming style — more maintainable in complex service orchestration.
fetchUserAsync()
.thenApply(this::enrichUser)
.thenApply(this::convertToDTO)
.thenAccept(this::sendResponse);
This is ideal for chaining multiple non-blocking microservice calls.
3.7. Integration with Frameworks
Frameworks like Spring, Quarkus, and Micronaut integrate well with CompletableFuture, allowing:
- Async controller responses (
@Async) - Asynchronous REST clients
- Event-driven architectures
Example in Spring Boot:
@Async
public CompletableFuture<User> getUserAsync() {
return CompletableFuture.completedFuture(fetchUser());
}
3.8. Improved Observability
Since CompletableFuture returns a future handle, you can:
- Log the status
- Trace completion
- Add hooks for telemetry or tracing
CompletableFuture.supplyAsync(this::remoteCall)
.whenComplete((result, ex) -> {
if (ex != null) logError(ex);
else logResult(result);
});
This aligns with observability best practices in microservices (logging, tracing, metrics).
4. Example: Comparing Thread vs. CompletableFuture
Using Plain Thread (Not Recommended):
public String fetchData() {
final String[] result = new String[1];
Thread thread = new Thread(() -> {
result[0] = callExternalService();
});
thread.start();
thread.join(); // blocks
return result[0];
}
Problems:
- Blocking
- Verbose
- Poor error handling
- Inefficient
Using CompletableFuture (Recommended):
public CompletableFuture<String> fetchDataAsync() {
return CompletableFuture.supplyAsync(this::callExternalService)
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> "fallback");
}
Advantages:
- Non-blocking
- Timeouts and fallbacks
- Composable
- Efficient resource usage
5. Real-World Use Cases in Microservices
Use Case 1: Parallel API Calls
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(this::fetchUser);
CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(this::fetchOrders);
CompletableFuture<Void> combined = CompletableFuture.allOf(userFuture, orderFuture);
combined.thenAccept(v -> {
User user = userFuture.join();
List<Order> orders = orderFuture.join();
process(user, orders);
});
Use Case 2: Async Orchestration
In a microservice gateway:
return getCustomer()
.thenCompose(customer -> getAccount(customer.getId()))
.thenApply(account -> mapToResponse(account));
Use Case 3: Graceful Degradation
return CompletableFuture.supplyAsync(this::callPaymentService)
.exceptionally(ex -> {
log.warn("Payment service down, using dummy response");
return dummyPaymentResponse();
});
