Writing9 min read
Cover illustration for the Java CompletableFuture error handling and Spring Boot integration article
Software EngineeringFebruary 16, 2026

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.

javaasyncconcurrencycompletablefuturespring-booterror-handling

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:

java
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 value
  • handle(fn): called on both success and failure, transforms the result either way
  • whenComplete(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.

java
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":

java
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:

java
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:

java
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.

java
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:

java
// Wrong: returns CompletableFuture<CompletableFuture<User>>
return fetchFromPrimary(userId)
    .exceptionally(ex -> fetchFromBackup(userId));

Use exceptionallyCompose to flatten it:

java
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:

java
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:

java
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:

java
@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 ready
  • maxPoolSize: maximum threads when the queue is full
  • queueCapacity: tasks waiting before new threads are created
  • threadNamePrefix: makes thread dumps readable during debugging
  • CallerRunsPolicy: 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:

java
@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

java
@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:

java
@Async("ioTaskExecutor")
public CompletableFuture<User> findUser(Long userId) { ... }

Pitfall 2: Internal calls bypass the proxy

java
@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

java
@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:

java
@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

java
// 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

  1. Handle errors explicitly. Unhandled async errors disappear silently with no stack trace.
  2. Use the right method: exceptionally for fallbacks, handle for transformation, whenComplete for side effects.
  3. Handle partial failures. One failed operation should not blank the entire response.
  4. Configure custom executors. Name your threads, size pools appropriately, set rejection policies.
  5. Prefer manual CompletableFuture over @Async. It is more explicit, more testable, and has no proxy surprises.
  6. Always specify the executor. @Async without 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.