Java CompletableFuture Exception Handling
java completablefuture exception handling: Learn how to handle exceptions in CompletableFuture chains using exceptionally, handle, and whenComplete, with practical exa...
When you chain CompletableFuture stages, an exception thrown in any stage is not thrown immediately. Instead, it is stored inside that CompletableFuture and passed to dependent stages. If you never handle it, the exception can be silently swallowed or cause the entire chain to fail in a way that is hard to debug. This article covers java completablefuture exception handling patterns: how exceptions propagate, how to recover with exceptionally, how to transform both results and errors with handle, and how to run side effects with whenComplete.
How Exceptions Propagate Through CompletableFuture Chains
Consider a simple chain:
CompletableFuture.supplyAsync(() -> { if (Math.random() > 0.5) { throw new IllegalStateException("boom"); } return 42; }) .thenApply(value -> value * 2) .thenAccept(System.out::println);
If the supplier throws, that exception is captured in the first CompletableFuture. The thenApply stage sees a completed future with an exception, so it does not run its function; it immediately completes with the same exception. The same happens for thenAccept. The exception propagates down the chain until a stage that handles it. If no stage handles it, the exception remains in the final CompletableFuture. Calling join() or get() on that future will throw an ExecutionException (or CompletionException for join()). Without an explicit handler, the exception is effectively lost unless you inspect the final future.
Using exceptionally to Recover from Failures
The exceptionally method takes a function that receives the Throwable and returns a fallback value. It runs only when the upstream stage completed exceptionally. The returned value becomes the result of the new CompletableFuture, so the chain can continue normally.
CompletableFuture<Integer> future = CompletableFuture .supplyAsync(() -> { if (Math.random() > 0.5) { throw new IllegalStateException("boom"); } return 42; }) .exceptionally(ex -> { System.err.println("Recovering from: " + ex.getMessage()); return -1; }); System.out.println(future.join()); // prints 42 or -1
exceptionally is the simplest way to provide a default value or a fallback computation. It is useful when you want to degrade gracefully, such as returning a cached value or a sentinel. Note that exceptionally returns a new CompletableFuture, so the original future remains unchanged. The exception is not rethrown; it is consumed by the handler.
Using handle to Transform Both Results and Errors
The handle method receives a BiFunction with the result (or null if exceptional) and the Throwable (or null if successful). It always runs, regardless of whether the upstream completed normally or exceptionally. This makes it suitable for cases where you need to produce a new result based on either outcome.
CompletableFuture<String> future = CompletableFuture .supplyAsync(() -> { if (Math.random() > 0.5) { throw new IllegalStateException("boom"); } return "success"; }) .handle((result, ex) -> { if (ex != null) { return "fallback: " + ex.getMessage(); } return result; }); System.out.println(future.join());
handle is more flexible than exceptionally because it can also transform the successful result. However, it requires you to check for the exception yourself. If you only need to recover from errors, exceptionally is more concise. If you need to map both outcomes to a common type, handle is the better choice.
Using whenComplete for Side Effects Without Changing the Result
The whenComplete method runs a BiConsumer with the result and the exception, but it does not change the result or the exception. It is intended for side effects like logging, metrics, or cleanup. The CompletableFuture returned by whenComplete completes with the same result or exception as the upstream stage.
CompletableFuture<Integer> future = CompletableFuture .supplyAsync(() -> 42) .whenComplete((result, ex) -> { if (ex != null) { System.err.println("Failed: " + ex.getMessage()); } else { System.out.println("Succeeded: " + result); } }); // The original result 42 is still available System.out.println(future.join()); // prints 42
If you want to log an exception but still propagate it, whenComplete is the right tool. It does not swallow the exception; it only observes it. This is important for observability without altering the control flow.
Exception Handling with thenCompose and thenApply
thenApply and thenCompose propagate exceptions exactly like the earlier examples. If the upstream stage fails, the function is not invoked, and the new stage completes with the same exception. You can attach an exceptionally after any of these stages to recover.
CompletableFuture<Integer> future = CompletableFuture .supplyAsync(() -> 10) .thenApply(value -> value / 0) // throws ArithmeticException .exceptionally(ex -> 0); System.out.println(future.join()); // prints 0
When using thenCompose, the inner CompletableFuture may also fail. That failure is flattened into the outer chain. For example:
CompletableFuture<Integer> future = CompletableFuture .supplyAsync(() -> 2) .thenCompose(value -> CompletableFuture.supplyAsync(() -> { if (value == 2) throw new RuntimeException("inner failure"); return value * 3; })) .exceptionally(ex -> -1);
Here the exception from the inner future is caught by the exceptionally on the outer chain. This is a common pattern when composing asynchronous operations that can each fail independently.
Handling Exceptions from Callbacks and Async Stages
When you use *Async variants like thenApplyAsync or supplyAsync, the callback runs on a different thread. Exceptions thrown inside those callbacks are still captured in the CompletableFuture, so the same handling methods work. However, you should be aware that the thread pool used for async stages can affect how exceptions are observed. If a task is cancelled or the executor rejects it, the CompletableFuture completes with a CancellationException or CompletionException. These are still Throwable instances and can be handled with the same techniques.
ExecutorService pool = Executors.newFixedThreadPool(2); CompletableFuture<Integer> future = CompletableFuture .supplyAsync(() -> { throw new IllegalArgumentException("async failure"); }, pool) .exceptionally(ex -> { System.err.println("Caught: " + ex); return 0; }); pool.shutdown();
One subtlety: if the callback itself throws an exception that is not an Error or RuntimeException, it is wrapped in a CompletionException. The exceptionally handler receives the CompletionException, and you may need to unwrap it to get the original cause. This is a common source of confusion.
Common Pitfalls and Operational Considerations
A frequent mistake is to call get() or join() without handling exceptions, which can cause ExecutionException to propagate and disrupt the calling thread. Always use exceptionally or handle before the terminal operation if you expect failures.
Another pitfall is swallowing exceptions without logging. Using exceptionally to return a default value is fine, but if you do not log the original exception, you lose critical diagnostic information. Consider combining whenComplete for logging and exceptionally for recovery.
From a performance perspective, each exceptionally, handle, or whenComplete creates a new CompletableFuture and adds a stage to the chain. In most applications this overhead is negligible, but in very high-throughput systems with millions of operations, the extra allocations can add up. If you are building a hot path, consider using a single handle that covers both success and failure instead of chaining multiple recovery stages.
Finally, remember that exceptionally and handle return a new CompletableFuture. If you store the original future and later call join() on it, you will still get the exception. Always work with the future returned by the recovery method.
Choosing the Right Recovery Method for Your Use Case
The following table summarizes the differences between the three main recovery methods:
| Method | When it runs | Can change result | Typical use case |
|---|---|---|---|
exceptionally | Only on exception | Yes | Provide a fallback value |
handle | Always | Yes | Transform both success and failure |
whenComplete | Always | No | Logging, metrics, cleanup |
Use exceptionally when you want to recover from a failure and continue with a default value. Use handle when you need to produce a different result type based on the outcome. Use whenComplete when you need to observe the outcome without altering it.
In practice, you will often combine these methods. For example, you might use whenComplete to log the exception and then exceptionally to return a cached value. The key is to be explicit about where exceptions are handled and to avoid leaving them unobserved in a chain.
When designing an asynchronous pipeline, decide early which stages are allowed to fail and which should recover. This makes the control flow clear and prevents exceptions from being silently lost. A good rule is to handle exceptions as close to the source as possible, but only if you can meaningfully recover. Otherwise, let the exception propagate to a central handler at the end of the chain.
For production systems, ensure that any exception that is not recovered is at least logged. whenComplete is a convenient place to do that without changing the outcome. If you are using a custom executor, also consider how thread pool exhaustion or rejection can affect exception handling; those failures are also captured as exceptions in the CompletableFuture and can be handled with the same methods.