
Java CompletableFuture, Part 3: Error Handling and Spring Boot Integration
How to handle errors in async Java code without losing them, and how to integrate CompletableFuture into Spring Boot with properly configured executors.
Part 1 covered the async fundamentals. Part 2 covered composition. This part covers what to do when those operations fail.
When Async Operations Fail
Here is a quiet failure mode that catches most developers off guard:
CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("This error vanishes into the void");
});
// No exception thrown here. No stack trace. Nothing.If you do not explicitly handle errors in CompletableFuture, they are silently swallowed. Your application continues running, something is broken, and you have no idea. This is why error handling in async code is not optional.
The Error Handling Trio
CompletableFuture gives you three methods for handling errors. Each has a specific purpose:
exceptionally(fn): called only on error, returns a fallback valuehandle(fn): called on both success and failure, transforms the result either waywhenComplete(fn): called on both success and failure, for side effects only (no result transformation)
exceptionally: Provide a Fallback
exceptionally is your catch block. It fires only when an exception occurs and returns a fallback value.
public CompletableFuture<User> fetchUserWithFallback(Long userId) {
return fetchUserThatMayFail(userId)
.exceptionally(throwable -> {
log.error("Failed to fetch user: {}", throwable.getMessage());
return User.empty();
});
}The caller receives either the actual user on success, or an empty user on failure. No exception propagates; the chain continues with the fallback value.
Handling specific exception types
Real applications have different failure modes. A "user not found" is different from "database is down":
public CompletableFuture<User> fetchUserWithTypedHandling(Long userId) {
return fetchUserAsync(userId)
.exceptionally(throwable -> {
Throwable cause = throwable.getCause() != null
? throwable.getCause()
: throwable;
if (cause instanceof UserNotFoundException) {
return User.empty();
} else if (cause instanceof ServiceUnavailableException) {
throw new RuntimeException("Service temporarily unavailable", cause);
} else {
throw new RuntimeException("Unexpected error", cause);
}
});
}Exceptions in CompletableFuture are wrapped in CompletionException. Always check getCause() to get the actual exception type.
handle: Transform Success and Failure
handle is called for both success and failure. It is more flexible than exceptionally when you need to transform the result in either case.
This works well with the Result pattern:
public record UserResult(boolean success, User user, String errorMessage) {
public static UserResult success(User user) {
return new UserResult(true, user, null);
}
public static UserResult failure(String errorMessage) {
return new UserResult(false, null, errorMessage);
}
}
public CompletableFuture<UserResult> fetchUserAsResult(Long userId) {
return fetchUserAsync(userId)
.handle((user, throwable) -> {
if (throwable != null) {
return UserResult.failure(throwable.getMessage());
}
return UserResult.success(user);
});
}The caller always gets a UserResult with no exception handling required:
UserResult result = fetchUserAsResult(123L).join();
if (result.success()) {
processUser(result.user());
} else {
showError(result.errorMessage());
}whenComplete: Side Effects Only
whenComplete is for logging, metrics, and cleanup. It does not transform the result or suppress exceptions.
public CompletableFuture<User> fetchUserWithLogging(Long userId) {
return fetchUserAsync(userId)
.whenComplete((user, throwable) -> {
if (throwable != null) {
log.error("ERROR fetching user {}: {}", userId, throwable.getMessage());
metrics.increment("user.fetch.error");
} else {
log.info("Successfully fetched user: {}", user.username());
metrics.increment("user.fetch.success");
}
});
// The original result (or exception) passes through unchanged
}The key distinction from handle: if there was an error, it still propagates. whenComplete observes but does not interfere.
Fallback to a Backup Service
A common pattern: try the primary service, fall back to a backup on failure. The naive approach produces a nested future:
// Wrong: returns CompletableFuture<CompletableFuture<User>>
return fetchFromPrimary(userId)
.exceptionally(ex -> fetchFromBackup(userId));Use exceptionallyCompose to flatten it:
public CompletableFuture<User> fetchUserWithBackup(Long userId) {
return fetchFromPrimaryService(userId)
.exceptionallyCompose(throwable -> {
log.warn("Primary failed, trying backup: {}", throwable.getMessage());
return fetchFromBackupService(userId);
});
}exceptionallyCompose (Java 12+) is to exceptionally what thenCompose is to thenApply. Use it when your fallback is itself async.
Partial Failure in Parallel Operations
When running multiple operations in parallel, some may succeed while others fail. Wrapping each future in a handle call preserves the successful results:
public CompletableFuture<DashboardResult> loadDashboardWithPartialFailure(Long userId) {
CompletableFuture<DataResult<User>> userFuture =
fetchUser(userId)
.handle((user, ex) -> ex != null
? DataResult.failure("user", ex.getMessage())
: DataResult.success("user", user));
CompletableFuture<DataResult<String>> ordersFuture =
fetchOrdersSummary(userId)
.handle((summary, ex) -> ex != null
? DataResult.failure("orders", ex.getMessage())
: DataResult.success("orders", summary));
CompletableFuture<DataResult<String>> paymentsFuture =
fetchPaymentsSummary(userId)
.handle((summary, ex) -> ex != null
? DataResult.failure("payments", ex.getMessage())
: DataResult.success("payments", summary));
return CompletableFuture.allOf(userFuture, ordersFuture, paymentsFuture)
.thenApply(ignored -> new DashboardResult(
userFuture.join(),
ordersFuture.join(),
paymentsFuture.join()
));
}Your dashboard can now show partial data with meaningful error states instead of a blank screen.
Chaining Error Handlers
You can chain multiple error handlers for different concerns:
public CompletableFuture<User> fetchUserWithChainedHandling(Long userId) {
return fetchUserThatMayFail(userId)
.whenComplete((user, ex) -> {
if (ex != null) {
log.error("Logging error: {}", ex.getMessage());
}
})
.exceptionallyCompose(ex -> {
log.info("Attempting backup recovery...");
return fetchUserFromBackupService(userId);
})
.exceptionally(ex -> {
log.error("All sources failed, returning empty user");
return User.empty();
});
}The chain reads clearly: log the error, try the backup, return empty if the backup also fails.
Spring Boot Integration
Configure Custom Executors
Never use ForkJoinPool.commonPool() in production. Configure dedicated executors with named threads, bounded pools, and rejection policies:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "ioTaskExecutor")
public Executor ioTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int cpuCores = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(cpuCores * 2);
executor.setMaxPoolSize(cpuCores * 4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("io-async-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
@Bean(name = "cpuTaskExecutor")
public Executor cpuTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int cpuCores = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(cpuCores);
executor.setMaxPoolSize(cpuCores);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("cpu-async-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
@Bean(name = "virtualThreadExecutor")
public Executor virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}Key decisions in this configuration:
corePoolSize: minimum threads always readymaxPoolSize: maximum threads when the queue is fullqueueCapacity: tasks waiting before new threads are createdthreadNamePrefix: makes thread dumps readable during debuggingCallerRunsPolicy: provides backpressure when the pool is exhausted
Service Layer: Manual CompletableFuture
Inject the executor via constructor and use it explicitly. This is the recommended approach for full control, clear code flow, and easy testing:
@Service
public class UserService {
private final Executor ioExecutor;
private final ExternalUserClient userClient;
private final PreferenceService preferenceService;
private final LoyaltyService loyaltyService;
public UserService(
@Qualifier("ioTaskExecutor") Executor ioExecutor,
ExternalUserClient userClient,
PreferenceService preferenceService,
LoyaltyService loyaltyService) {
this.ioExecutor = ioExecutor;
this.userClient = userClient;
this.preferenceService = preferenceService;
this.loyaltyService = loyaltyService;
}
public CompletableFuture<User> findUserAsync(Long userId) {
return CompletableFuture.supplyAsync(
() -> userClient.fetchUser(userId),
ioExecutor
);
}
public CompletableFuture<UserProfile> getUserProfileAsync(Long userId) {
CompletableFuture<User> userFuture = findUserAsync(userId);
CompletableFuture<List<String>> prefsFuture =
CompletableFuture.supplyAsync(
() -> preferenceService.getPreferences(userId),
ioExecutor
);
CompletableFuture<Integer> pointsFuture =
CompletableFuture.supplyAsync(
() -> loyaltyService.getPoints(userId),
ioExecutor
);
return CompletableFuture.allOf(userFuture, prefsFuture, pointsFuture)
.thenApplyAsync(ignored -> UserProfile.of(
userFuture.join(),
prefsFuture.join(),
LocalDateTime.now(),
pointsFuture.join(),
calculatePlan(userFuture.join().tier(), pointsFuture.join())
), ioExecutor);
}
public CompletableFuture<User> findUserWithFallbackAsync(Long userId) {
return findUserAsync(userId)
.exceptionallyAsync(throwable -> {
log.error("Failed to fetch user {}: {}", userId, throwable.getMessage());
return User.empty();
}, ioExecutor);
}
}The @Async Trap
Spring's @Async annotation looks convenient but carries three pitfalls that matter in production.
Pitfall 1: The default executor creates unbounded threads
@Async
public CompletableFuture<User> findUser(Long userId) {
return CompletableFuture.completedFuture(userClient.fetchUser(userId));
}SimpleAsyncTaskExecutor creates a new thread for every call. Under load, this exhausts system resources. Always name the executor explicitly:
@Async("ioTaskExecutor")
public CompletableFuture<User> findUser(Long userId) { ... }Pitfall 2: Internal calls bypass the proxy
@Service
public class UserService {
@Async("ioTaskExecutor")
public CompletableFuture<User> findUserAsync(Long userId) {
return CompletableFuture.completedFuture(userClient.fetchUser(userId));
}
public User findUserSync(Long userId) {
// This does not work as expected.
// Internal calls bypass Spring's proxy.
CompletableFuture<User> future = findUserAsync(userId);
return future.join(); // Runs synchronously
}
}@Async works through Spring's proxy mechanism. Calling a method from within the same class bypasses the proxy, and the annotation is ignored.
Pitfall 3: Void return swallows exceptions
@Async("ioTaskExecutor")
public void sendNotification(Long userId, String message) {
// If this throws, the exception is silently swallowed
notificationService.send(userId, message);
}Unless you configure AsyncUncaughtExceptionHandler, exceptions in void async methods disappear.
When to use @Async
- Simple fire-and-forget operations where the result is not needed
- When all callers are external to the class
- When the annotation's simplicity clearly outweighs its limitations
When to avoid @Async
- Complex async workflows with chaining
- When you need fine-grained error handling
- When methods might be called internally
- When you want explicit, testable code
Prefer manual CompletableFuture. The explicit code is clearer, more testable, and has no proxy behavior to reason about.
Controller Layer
Controllers should return CompletableFuture. Spring handles the async response automatically without blocking the servlet thread:
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public CompletableFuture<ResponseEntity<User>> getUser(@PathVariable Long id) {
return userService.findUserAsync(id)
.thenApply(ResponseEntity::ok)
.exceptionally(ex -> ResponseEntity.notFound().build());
}
@GetMapping("/{id}/profile")
public CompletableFuture<ResponseEntity<UserProfile>> getProfile(@PathVariable Long id) {
return userService.getUserProfileAsync(id)
.thenApply(ResponseEntity::ok)
.exceptionally(ex -> {
log.error("Failed to load profile for user {}", id, ex);
return ResponseEntity.internalServerError().build();
});
}
}Spring returns immediately, completes the HTTP response when the future completes, and surfaces errors through the exceptionally handler.
Error Handling Quick Reference
// Fallback value on error
.exceptionally(ex -> defaultValue)
// Async fallback (when the fallback is itself a future)
.exceptionallyCompose(ex -> fallbackAsync())
// Transform both success and failure
.handle((result, ex) -> transform(result, ex))
// Side effects only (logging, metrics)
.whenComplete((result, ex) -> logAndMetric(result, ex))
// Chain: log, then try backup, then final fallback
.whenComplete((r, ex) -> log(ex))
.exceptionallyCompose(ex -> tryBackup())
.exceptionally(ex -> finalFallback)Key Takeaways
- Handle errors explicitly. Unhandled async errors disappear silently with no stack trace.
- Use the right method:
exceptionallyfor fallbacks,handlefor transformation,whenCompletefor side effects. - Handle partial failures. One failed operation should not blank the entire response.
- Configure custom executors. Name your threads, size pools appropriately, set rejection policies.
- Prefer manual
CompletableFutureover@Async. It is more explicit, more testable, and has no proxy surprises. - Always specify the executor.
@Asyncwithout an executor name uses unbounded thread creation.
Up Next: Production Patterns and Testing
Part 4 covers production concerns: safe fire-and-forget patterns, how to keep systems stable under high event load, and six strategies for writing deterministic async tests.