Parallel REST Calls in Spring with CompletableFuture
Learn to make multiple REST calls in parallel using CompletableFuture in Spring Boot.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Basic knowledge of Spring Boot and REST APIs
- ✓Familiarity with Java 8 lambdas and functional interfaces
- ✓Understanding of thread pools and concurrency
- Use CompletableFuture to make non-blocking parallel REST calls.
- Combine async results using allOf() or join().
- Configure a custom thread pool to avoid saturating the common ForkJoinPool.
- Handle exceptions per future with exceptionally() or handle().
- Use Spring's @Async for cleaner code, but watch out for proxy limitations.
Imagine you need to ask three different experts for information. Instead of asking one, waiting for the answer, then asking the next, you send emails to all three at once and wait for all replies. CompletableFuture in Java works like sending those emails – your program can continue doing other things while waiting for the answers, then combine them when all arrive.
You're building a dashboard that aggregates data from three microservices: user details from the User Service, order history from the Order Service, and payment status from the Payment Service. If you call them sequentially, your response time is the sum of all three – easily 600ms if each takes 200ms. But if you call them in parallel, the total time drops to roughly the slowest service – maybe 250ms.
That's the promise of parallel REST calls. But in production, naive parallelism can bring your application to its knees. I've seen a fintech startup's Spring Boot app crash on Black Friday because they used the default ForkJoinPool for all async calls – the thread pool got saturated, and the app became unresponsive.
In this article, I'll show you how to make multiple REST calls in parallel using CompletableFuture in Spring Boot – the right way. We'll cover thread pool configuration, error handling, and debugging strategies that I've learned from hard-won production experience. By the end, you'll be able to slash your API response times without sacrificing stability.
Why Sequential REST Calls Hurt Performance
Let's start with a concrete example. You have a Spring Boot service that needs to fetch user data, order history, and payment status for a dashboard. If you call these services one after another, the total response time is the sum of all three calls. If each takes 200ms, that's 600ms. Users hate waiting more than 500ms for a page load.
Here's the sequential approach using RestTemplate:
``java public Dashboard getDashboard(String userId) { User user = restTemplate.getForObject("http://user-service/users/" + userId, User.class); List<Order> orders = restTemplate.getForObject("http://order-service/orders?userId=" + userId, List.class); Payment payment = restTemplate.getForObject("http://payment-service/payments?userId=" + userId, Payment.class); return new Dashboard(user, orders, payment); } ``
This is simple but slow. In production, you'll see response times that are the sum of each call's latency. Worse, if one service is slow (e.g., 5 seconds), the whole request takes 5+ seconds. Users will abandon the page.
The fix is to make these calls in parallel. But as we'll see, doing it right requires more than just wrapping each call in a CompletableFuture.
Parallel REST Calls with CompletableFuture
CompletableFuture is Java's answer to asynchronous programming. It lets you run tasks in parallel and combine their results when all are done. Here's how to refactor the sequential code to parallel:
```java public Dashboard getDashboard(String userId) { CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://user-service/users/" + userId, User.class)); CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://order-service/orders?userId=" + userId, List.class)); CompletableFuture<Payment> paymentFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://payment-service/payments?userId=" + userId, Payment.class));
CompletableFuture.allOf(userFuture, ordersFuture, paymentFuture).join();
return new Dashboard(userFuture.join(), ordersFuture.join(), paymentFuture.join()); } ```
This works, but there's a hidden danger: supplyAsync() uses the common ForkJoinPool by default. That pool is designed for CPU-bound tasks, not I/O-bound operations like REST calls. Under load, you'll exhaust the pool and cause thread starvation.
Always provide a custom executor. Here's the better version:
```java private final ExecutorService executor = Executors.newFixedThreadPool(50);
public Dashboard getDashboard(String userId) { CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://user-service/users/" + userId, User.class), executor); // ... same for others } ```
This gives you control over the thread pool size. A good starting point is number of concurrent requests * number of parallel calls per request. Monitor and adjust.
Executors.newCachedThreadPool() which created an unbounded number of threads. Under load, it created thousands of threads, causing the JVM to crash with OutOfMemoryError. Use fixed thread pools or bounded thread pools.Handling Errors in Parallel Calls
When you make multiple parallel calls, one failure shouldn't bring down the entire operation. For example, if the Payment Service is down, you might still want to show user info and order history, with a note that payment info is unavailable.
CompletableFuture provides and exceptionally() to deal with errors per future. Here's how to provide fallback values:handle()
``java CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> restTemplate.getForObject("http://user-service/users/" + userId, User.class), executor) .exceptionally(ex -> { log.error("Failed to fetch user", ex); return new ``User(); // fallback empty user });
Then, when you call allOf() and , the fallback values are used, and no exception is thrown. But be careful: join()allOf() itself doesn't propagate exceptions; you need to check each future individually.
A common mistake is to call future.get() or future.join() without exception handling, which throws CompletionException wrapping the original exception. Always use exceptionally() or handle() before joining.
If you need to collect errors and report them later, you can use a helper like:
``java public <T> CompletableFuture<T> withFallback(CompletableFuture<T> future, T fallback) { return future.exceptionally(ex -> { log.error("Async operation failed", ex); return fallback; }); } ``
This keeps your code clean and consistent.
exceptionally() or handle() on each future to provide fallback values and prevent one failure from crashing the entire request.Using Spring's @Async for Cleaner Code
Spring's @Async annotation allows you to make any method asynchronous without explicit CompletableFuture code. Here's how:
- Enable async in your configuration:
``java @Configuration @EnableAsync public class AsyncConfig { @Bean("asyncExecutor") public Executor asyncExecutor() { ThreadPoolTaskExecutor executor = new ``ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-"); executor.initialize(); return executor; } }
- Create service methods annotated with
@Async:
``java @Async("asyncExecutor") public CompletableFuture<User> fetchUser(String userId) { User user = restTemplate.getForObject("http://user-service/users/" + userId, User.class); return CompletableFuture.completedFuture(user); } ``
- Call them from your controller or service:
``java public Dashboard getDashboard(String userId) { CompletableFuture<User> userFuture = fetchUser(userId); CompletableFuture<List<Order>> ordersFuture = fetchOrders(userId); CompletableFuture<Payment> paymentFuture = fetchPayment(userId); CompletableFuture.allOf(userFuture, ordersFuture, paymentFuture).join(); return new Dashboard(userFuture.join(), ordersFuture.join(), paymentFuture.join()); } ``
The advantage is that you can reuse these async methods elsewhere. But beware: @Async only works if the method is called from outside the class (through Spring's proxy). Calling fetchUser() from within the same class won't make it async – it will run synchronously. This is a common pitfall.
Also, @Async methods must return CompletableFuture (or Future), not void, to be useful for composition.
What the Official Docs Won't Tell You
The Spring documentation and Java docs for CompletableFuture are technically correct, but they gloss over several production realities:
1. The common ForkJoinPool is NOT your friend.
The docs say supplyAsync() uses ForkJoinPool.commonPool() by default. They don't emphasize that this pool is tiny (size = CPU cores - 1) and designed for CPU-bound tasks. Using it for I/O will cause thread starvation. Always provide a custom executor.
2. allOf() doesn't propagate exceptions the way you think.
CompletableFuture.allOf() returns a new CompletableFuture that completes when all given futures complete. But if any future completes exceptionally, the resulting future also completes exceptionally. However, the exception is a CompletionException wrapping the first exception encountered. The other futures might still be running or have completed exceptionally too. To handle all errors, you need to inspect each future individually after allOf().join().
3. Thread pool sizing is an art, not a science.
The formula pool size = number of concurrent requests * number of parallel calls is a starting point. But you also need to consider the downstream service's capacity. If you send too many concurrent requests, you might overwhelm the downstream service, causing it to slow down or fail. Use circuit breakers (Resilience4j) to protect downstream services.
4. CompletableFuture.get() without timeout is a time bomb.
If a downstream service hangs, your thread will block forever. Always use get(long timeout, TimeUnit unit) and handle TimeoutException. Even better, configure timeouts at the HTTP client level (RestTemplate or WebClient).
5. Memory leaks from uncompleted futures.
If you create a CompletableFuture but never complete it (e.g., due to a bug), it stays in memory. Over time, these leaked futures can cause OutOfMemoryError. Use CompletableFuture.completeOnTimeout() (Java 9+) or ensure all paths complete.
6. @Async and @Transactional don't mix well.
If you mark an @Async method as @Transactional, the transaction is bound to the async thread, not the caller's thread. This can lead to unexpected behavior. Keep async methods free of transaction management.
Using WebClient for Non-Blocking I/O
RestTemplate is blocking – each call occupies a thread while waiting for the response. This is fine for moderate concurrency, but if you need high throughput (thousands of requests per second), consider WebClient, which is non-blocking and uses event loops.
WebClient works naturally with CompletableFuture because you can convert its reactive Mono to a CompletableFuture:
```java Mono<User> userMono = webClient.get() .uri("http://user-service/users/{id}", userId) .retrieve() .bodyToMono(User.class);
CompletableFuture<User> userFuture = userMono.toFuture(); ```
This gives you the best of both worlds: non-blocking I/O and the composability of CompletableFuture. However, you need to be careful with thread pools: WebClient uses its own event loop threads, so you don't need a large thread pool for the futures themselves. But you still need an executor for any blocking operations within the async chain.
Here's a complete example:
```java @Service public class WebClientService { private final WebClient webClient;
public WebClientService(WebClient.Builder builder) { this.webClient = builder.baseUrl("http://user-service").build(); }
public CompletableFuture<User> fetchUser(String userId) { return webClient.get() .uri("/users/{id}", userId) .retrieve() .bodyToMono(User.class) .toFuture(); } } ```
Then you can combine futures as before. Note that toFuture() returns a CompletableFuture that completes when the Mono completes. No explicit executor needed because WebClient handles the threading.
But beware: if you use on a Mono inside a CompletableFuture, you'll block the event loop thread, defeating the purpose. Always stay in the reactive or async world.block()
Black Friday ForkJoinPool Saturation
CompletableFuture.supplyAsync() would automatically scale because it uses threads from the common ForkJoinPool.Executors.newFixedThreadPool() and passed it to supplyAsync(). Also added a dedicated pool for async operations to isolate from other tasks.- Never use the common ForkJoinPool for I/O-bound tasks; it's designed for CPU-bound tasks.
- Always size your thread pool based on expected concurrency and I/O wait time.
- Monitor thread pool metrics (active threads, queue size) in production.
- Use a separate thread pool for async operations to avoid interference with other parts of the application.
- Implement circuit breakers to fail fast when downstream services are slow.
ForkJoinPool.commonPool(). If many threads are waiting, you've exhausted the common pool. Switch to a custom thread pool with a larger size.CompletableFuture.get() never returns or throws TimeoutExceptionexceptionally() or handle() to provide fallback values per future. Combine with allOf() and handle exceptions individually, not after join().jstack <pid> | grep -A 10 'ForkJoinPool.commonPool'jstack <pid> | grep 'WAITING' | wc -l| File | Command / Code | Purpose |
|---|---|---|
| SequentialDashboardService.java | public class SequentialDashboardService { | Why Sequential REST Calls Hurt Performance |
| ParallelDashboardService.java | public class ParallelDashboardService { | Parallel REST Calls with CompletableFuture |
| ResilientDashboardService.java | public class ResilientDashboardService { | Handling Errors in Parallel Calls |
| AsyncService.java | @Service | Using Spring's @Async for Cleaner Code |
| TimeoutExample.java | CompletableFuture | What the Official Docs Won't Tell You |
| WebClientAsyncService.java | @Service | Using WebClient for Non-Blocking I/O |
Key takeaways
exceptionally() to prevent one failure from ruining the entire result.Interview Questions on This Topic
How would you make multiple REST calls in parallel using CompletableFuture in Spring Boot?
join(). Handle exceptions with exceptionally() or handle().Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Spring Boot. Mark it forged?
5 min read · try the examples if you haven't