Back to Blog
javacompletablefutureasyncspring-bootconcurrencyjava-21

CompletableFuture in Spring Boot: Practical Guide to Async Java (2026)

CompletableFuture enables non-blocking async programming in Java. Learn how to use it in Spring Boot for parallel calls, timeout handling, and error recovery - with real examples.

J

JOptimize Team

May 23, 2026· 9 min read

CompletableFuture is Java's primary tool for composing asynchronous operations - parallel HTTP calls, non-blocking I/O, timeout-bounded tasks. Used correctly, it dramatically improves throughput. Used incorrectly, it silently swallows exceptions, blocks threads, and creates race conditions that are nearly impossible to debug.


The Basic Pattern

// Run a task asynchronously CompletableFuture<User> future = CompletableFuture.supplyAsync(() -> { return userRepository.findById(userId); // Runs on ForkJoinPool.commonPool() }); // Transform the result CompletableFuture<UserDto> dtoFuture = future .thenApply(user -> UserMapper.toDto(user)); // Get the result (blocks current thread) UserDto dto = dtoFuture.get(5, TimeUnit.SECONDS);

Critical: Always use a named executor, never the default ForkJoinPool.commonPool():

@Configuration public class AsyncConfig { @Bean("ioExecutor") public ExecutorService ioExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); // Java 21+ // Or: new ThreadPoolExecutor(10, 50, 60, SECONDS, new ArrayBlockingQueue<>(100)) } } // Pass your executor to every supplyAsync: CompletableFuture.supplyAsync(() -> callExternalApi(), ioExecutor);

The common pool is shared across your JVM. One blocking call starves other futures.


Parallel Calls - The Main Use Case

@Service @RequiredArgsConstructor public class ProductPageService { private final ProductService productService; private final ReviewService reviewService; private final InventoryService inventoryService; private final ExecutorService ioExecutor; public ProductPageDto getProductPage(Long productId) { // Launch all three in parallel CompletableFuture<Product> productFuture = CompletableFuture.supplyAsync( () -> productService.findById(productId), ioExecutor); CompletableFuture<List<Review>> reviewsFuture = CompletableFuture.supplyAsync( () -> reviewService.findByProduct(productId), ioExecutor); CompletableFuture<Integer> stockFuture = CompletableFuture.supplyAsync( () -> inventoryService.getStock(productId), ioExecutor); // Wait for all three CompletableFuture.allOf(productFuture, reviewsFuture, stockFuture).join(); return new ProductPageDto( productFuture.join(), reviewsFuture.join(), stockFuture.join() ); // Total time = max(product, reviews, stock) instead of sum } }

If product=100ms, reviews=200ms, stock=150ms - sequential takes 450ms, parallel takes 200ms.


Timeout Handling

// completeOnTimeout: return fallback after timeout (Java 9+) CompletableFuture<List<Review>> reviewsFuture = CompletableFuture.supplyAsync(() -> reviewService.findByProduct(productId), ioExecutor) .completeOnTimeout(List.of(), 300, TimeUnit.MILLISECONDS); // Empty list if slow // orTimeout: throw TimeoutException after timeout (Java 9+) CompletableFuture<Product> productFuture = CompletableFuture.supplyAsync(() -> productService.findById(productId), ioExecutor) .orTimeout(500, TimeUnit.MILLISECONDS); // Fail fast

Error Handling

// exceptionally: recover from any exception CompletableFuture<List<Review>> reviewsFuture = CompletableFuture.supplyAsync(() -> reviewService.findByProduct(productId), ioExecutor) .exceptionally(ex -> { log.warn("Reviews unavailable for product {}: {}", productId, ex.getMessage()); return List.of(); // Graceful degradation }); // handle: process both success and failure CompletableFuture<ProductDto> result = CompletableFuture.supplyAsync(() -> productService.findById(productId), ioExecutor) .handle((product, ex) -> { if (ex != null) { log.error("Product load failed", ex); throw new ProductNotFoundException(productId); } return ProductMapper.toDto(product); }); // whenComplete: side effects without modifying result .whenComplete((result, ex) -> { if (ex != null) metricsService.recordFailure("product-load"); else metricsService.recordSuccess("product-load"); });

Chaining Operations

// thenApply: transform result synchronously CompletableFuture<String> name = userFuture .thenApply(user -> user.getFirstName() + " " + user.getLastName()); // thenCompose: chain async operations (flatMap) CompletableFuture<List<Order>> orders = userFuture .thenCompose(user -> CompletableFuture.supplyAsync( () -> orderService.findByUser(user.getId()), ioExecutor )); // thenCombine: combine two independent futures CompletableFuture<OrderSummary> summary = productFuture .thenCombine(priceFuture, (product, price) -> new OrderSummary(product, price));

Spring Boot Integration with @Async

@Service public class EmailService { @Async("ioExecutor") public CompletableFuture<Void> sendWelcomeEmail(User user) { emailClient.send(new WelcomeEmail(user)); return CompletableFuture.completedFuture(null); } } // Caller: CompletableFuture<Void> emailFuture = emailService.sendWelcomeEmail(user); // Don't block - fire and forget, or handle the future emailFuture.exceptionally(ex -> { log.error("Email send failed for user {}", user.getId(), ex); return null; });

Common Mistakes to Avoid

  • Not handling exceptions - an unhandled exception in CompletableFuture is silently swallowed unless you call .get() or chain .exceptionally()
  • Using ForkJoinPool.commonPool() for blocking I/O - this pool is designed for CPU-bound work; use a dedicated ExecutorService with virtual threads for I/O
  • Calling .get() without timeout - this blocks the calling thread indefinitely; always use .get(timeout, unit) or .orTimeout()
  • Blocking inside a CompletableFuture - calling Thread.sleep() or another .get() inside a supplyAsync() lambda defeats the purpose and can deadlock

Summary

CompletableFuture enables parallel execution that reduces total response time to the slowest call rather than the sum of all calls. Always use a dedicated executor (virtual threads in Java 21), handle timeouts with completeOnTimeout() or orTimeout(), chain error recovery with exceptionally(), and never let exceptions propagate silently. The pattern of launching parallel tasks and collecting results with allOf().join() is the most valuable use case for most Spring Boot services.


Detect Async Anti-Patterns in Your Codebase

JOptimize flags unhandled CompletableFuture exceptions, blocking calls inside async lambdas, and missing timeout configurations in your Spring Boot project.

Write safer async Java code - free scan, no configuration required.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.