Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Java CompletableFuture makes asynchronous programming more flexible, but failures can be harder to follow than in straight-line synchronous code. Exceptions do not always appear where they are thrown; instead, they are captured inside the future and propagated through dependent stages until a stage handles them or the final result is inspected.
Effective error handling means knowing which APIs recover from failures, which transform outcomes, and which only observe completion. Methods such as exceptionally(), handle(), whenComplete(), and exceptionallyCompose() each serve a different role in building predictable async pipelines.
As an Amazon Associate I earn from qualifying purchases.
Understanding these patterns helps prevent swallowed failures, duplicated logging, blocked recovery paths, and confusing wrapper exceptions from join(), get(), or combined operations like allOf(). With the right approach, CompletableFuture workflows can remain reliable, readable, and resilient under failure.
PC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchHow CompletableFuture Propagates Exceptions
A CompletableFuture has one terminal state: it completes normally with a value, completes exceptionally with a failure, or is cancelled. Once a stage completes exceptionally, dependent stages that require its successful result are usually skipped and completed exceptionally as well. This is what makes failures flow through an asynchronous pipeline in a similar way to exceptions moving up a synchronous call stack, but the exception is stored in the future instead of being thrown immediately on the worker thread.
For example, if a stage created with supplyAsync() throws an exception, the returned CompletableFuture is completed exceptionally. A following thenApply() does not run because it needs the successful value from the previous stage. The thenApply() stage is instead completed exceptionally with the same underlying failure, typically wrapped by CompletionException when observed through join().
Propagation depends on the kind of dependent stage you attach. Success-only callbacks such as thenApply, thenAccept, thenRun, thenCompose, and their async variants run only when the upstream stage completes normally. Error-aware callbacks such as exceptionally, handle, and whenComplete are invoked when a failure occurs, depending on their contract. This distinction is central to designing a pipeline: each stage either assumes a valid value, recovers from failure, transforms both outcomes, or observes completion.
- Success path:
thenApply()receives the previous result only if the previous stage completed normally. - Failure path: if the previous stage failed, success-only stages are bypassed and marked failed.
- Recovery path:
exceptionally()can convert a failure into a fallback value, allowing later success-only stages to continue. - Observation path:
whenComplete()can inspect the result or error without automatically recovering from it.
Exceptions thrown inside continuation callbacks also propagate. If a thenApply() callback receives a valid value but throws IllegalStateException, the stage returned by thenApply() completes exceptionally. Downstream stages then see that new failure, not the original successful value. Similarly, if an error-handling callback itself throws, the replacement exception usually becomes the failure of the returned stage.
Cancellation follows the same exceptional-completion model. Calling cancel() completes the future exceptionally with CancellationException, and dependent stages that cannot run without the cancelled result also complete exceptionally. Although cancellation is represented as exceptional completion, it often deserves separate handling because it may indicate a deliberate timeout, user action, or shutdown rather than a system error.
When mulle futures are combined, propagation can be less obvious. With thenCombine(), the combining function runs only if both inputs complete normally; if either input fails, the combined future fails. With allOf(), the returned future completes normally only after all component futures complete normally, and completes exceptionally if at least one component fails. The aggregate future does not directly return all individual errors, so reliable workflows often inspect each component future separately after the aggregate completes.
The practical model is to treat every asynchronous pipeline as having two tracks: a value track and an error track. Ordinary transformation stages stay on the value track. Recovery stages can move execution from the error track back to the value track. Observation stages can record what happened without changing the track unless they throw a new exception. Understanding that flow makes the behavior of larger CompletableFuture chains predictable and easier to test.
Using exceptionally() for Simple Recovery
exceptionally() is the simplest recovery API in CompletableFuture. It lets you provide a fallback value when the previous stage completes exceptionally. If the previous stage succeeds, the recovery function is skipped and the successful result passes through unchanged. If it fails, the function receives the thrown exception and returns a replacement value of the same type.
The Tool Desk
Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →A common use case is returning a safe default when an asynchronous operation is useful but not mandatory. For example, if a profile service fails while loading a user dashboard, you might continue with a placeholder profile instead of failing the entire page render. The constraint is that exceptionally() recovers by returning a normal value, not by starting another asynchronous operation.
CompletableFuture<UserProfile> profileFuture =
CompletableFuture.supplyAsync(() -> profileClient.fetch(userId))
.exceptionally(ex -> {
logger.warn("Could not load profile for user {}", userId, ex);
return UserProfile.guest(userId);
});
In this pipeline, an exception thrown by fetch() causes the future returned by supplyAsync() to complete exceptionally. The exceptionally() stage intercepts that failure, logs it, and completes normally with a guest profile. Downstream stages then see a regular UserProfile, not an exception.
How exceptionally() affects the pipeline
- On success: the original value continues unchanged.
- On failure: the recovery function runs and its return value becomes the new successful result.
- If recovery throws: the new stage also completes exceptionally.
- Type stays the same:
CompletableFuture<T>remainsCompletableFuture<T>.
This makes exceptionally() a good fit for local, synchronous fallback behavior: default configuration, cached placeholder data, empty collections, or a degraded response. For example, if a recommendation query fails, returning List.of() may be better than failing checkout or page loading.
CompletableFuture<List<Recommendation>> recommendations =
CompletableFuture.supplyAsync(() -> recommendationService.forUser(userId))
.exceptionally(ex -> List.of());
Be careful not to hide serious failures accidentally. Since exceptionally() converts an exceptional completion into a successful one, downstream code cannot tell that a problem occurred unless the fallback value carries that information or the error is logged, counted, or otherwise observed. Avoid using broad fallback values when the failure should stop the workflow, such as payment authorization, inventory reservation, or permission checks.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Rank #2
Exception type and wrapping
The exception passed to exceptionally() is often a CompletionException, especially when the failure came from another completion stage. The original cause is usually available through ex.getCause(). For selective recovery, inspect the cause and rethrow when the error is not recoverable.
CompletableFuture<Price> priceFuture =
CompletableFuture.supplyAsync(() -> pricingClient.getPrice(sku))
.exceptionally(ex -> {
Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex;
if (cause instanceof TimeoutException) {
return Price.unavailable(sku);
}
throw new CompletionException(cause);
});
This pattern keeps recovery precise. A timeout may produce a controlled fallback, while a data corruption error, authentication failure, or programming bug continues as an exceptional completion. In reliable asynchronous workflows, exceptionally() works best when the fallback is intentional, cheap, and safe for downstream stages to consume.
Do these 3 things before closing this tab:
1Fix the driver behind crashes, sound loss and screen glitches2Repair Windows errors before they cause bigger problems3Scan for outdated or missing drivers - takes under a minuteUsing handle() to Transform Success and Failure
handle() is the most symmetrical recovery method in CompletableFuture: it receives both the successful result and the failure, then returns a new value for the next stage. Its callback has the shape (result, throwable) -> replacement. Exactly one of those inputs is normally meaningful: result is populated when the previous stage completed successfully, while throwable is populated when it completed exceptionally.
This makes handle() useful when an async pipeline must normalize both outcomes into a single type. For example, a service call that fetches a user profile might return a default profile on failure, enrich a real profile on success, and always continue with a ProfileView. Unlike exceptionally(), which only runs after failure, handle() always runs after the preceding stage completes.
CompletableFuture<ProfileView> viewFuture =
fetchProfile(userId)
.handle((profile, error) -> {
if (error != null) {
return ProfileView.guest(userId);
}
return ProfileView.from(profile);
});
The value returned from handle() becomes the successful result of the new CompletableFuture, unless the handler itself throws an exception. In that case, the returned future completes exceptionally with the new failure. This means handle() can recover from an upstream exception, but it can also replace it with a more domain-specific exception if recovery is not appropriate.
CompletableFuture<Order> summaryFuture =
loadOrder(orderId)
.handle((order, error) -> {
if (error != null) {
throw new OrderLookupException("Could not load order " + orderId, error);
}
return OrderSummary.from(order);
});
Choosing between handle() and exceptionally()
Use exceptionally() when only the failure path needs a fallback and the success value should pass through unchanged. Use handle() when the next stage should be computed from either success or failure, or when success and failure should both be converted into a common response type.
| API | Runs on success | Runs on failure | Typical use |
|---|---|---|---|
exceptionally() |
No | Yes | Provide a simple fallback after an exception |
handle() |
Yes | Yes | Convert both outcomes into a new value |
Be careful not to hide failures unintentionally. Since returning a value from handle() turns the next future into a successful completion, a broad fallback can make monitoring and callers believe the operation succeeded. For business-level fallbacks, this may be correct. For infrastructure failures such as database outages, authentication errors, or corrupted data, it is often better to wrap and rethrow so the pipeline remains failed.
Like other CompletableFuture transformations, handle() also has an async variant: handleAsync(). The non-async form usually runs in the thread that completes the previous stage, while handleAsync() schedules the handler on the default executor or a supplied Executor. Use a dedicated executor when the handler performs blocking work, expensive mapping, or calls another service, so completion threads are not delayed.
Using whenComplete() for Side Effects and Logging
whenComplete() is used when you want to observe the result of a CompletableFuture without changing its outcome. It receives both the successful value and the failure, if one occurred, making it a good fit for logging, metrics, tracing, cleanup, and audit events. Unlike exceptionally() and handle(), it is not primarily a recovery API. In the normal case, the original result or exception continues downstream unchanged.
Rank #3
The callback passed to whenComplete() takes two arguments: the result and a Throwable. If the stage completed successfully, the result is populated and the exception is null. If the stage completed exceptionally, the result is typically null and the exception contains the failure. This lets you attach diagnostic behavior at points in an async pipeline without accidentally converting a failed operation into a successful one.
CompletableFuture<User> userFuture =
userClient.fetchUser(userId)
.whenComplete((user, error) -> {
if (error != null) {
logger.warn("Failed to fetch user {}", userId, error);
} else {
logger.info("Fetched user {}", user.id());
}
});
In this example, whenComplete() logs the outcome but does not recover from the failure. If fetchUser() completes exceptionally, userFuture is still completed exceptionally. A later stage such as thenApply() will be skipped, while a later exceptionally(), handle(), or exceptionallyCompose() can still process the error.
How whenComplete() differs from handle()
handle() transforms the pipeline because it returns a replacement value. whenComplete() observes the pipeline because its callback returns void. That distinction matters when you want predictable error behavior. Use handle() when you want to map both success and failure into a new value. Use whenComplete() when you want the original completion state to remain intact.
| API | Callback returns | Can recover? | Common use |
|---|---|---|---|
whenComplete() |
void |
No, not directly | Logging, metrics, cleanup |
handle() |
A new value | Yes | Transforming success or failure |
Exceptions thrown inside whenComplete()
The callback itself should be kept safe. If the original future succeeds and the whenComplete() callback throws an exception, the returned stage completes exceptionally with that new exception. If the original future already failed and the callback also throws, the original failure is generally the one that remains visible, with the callback failure potentially attached as suppressed depending on the implementation path. For that reason, logging and metrics code inside whenComplete() should avoid throwing unchecked exceptions.
CompletableFuture<Order> orderFuture =
orderService.load(orderId)
.whenComplete((order, error) -> {
try {
metrics.increment(error == null ? "order.load.success" : "order.load.failure");
} catch (RuntimeException metricsError) {
logger.debug("Metrics recording failed", metricsError);
}
});
There are also asynchronous variants: whenCompleteAsync() and whenCompleteAsync(BiConsumer, Executor). The non-async version may run in the thread that completes the previous stage, so it should not perform blocking I/O or expensive work. If logging is lightweight, the regular form is usually fine. If the side effect may block or take noticeable time, use an explicit executor so completion threads are not delayed.
- Use
whenComplete()for observing outcomes, not for fallback values. - Keep callbacks short, non-blocking, and resilient to their own failures.
- Use
whenCompleteAsync(..., executor)for heavier side effects. - Place it where the context is still available, such as request IDs, user IDs, or operation names.
- Chain recovery after it if the pipeline still needs to convert failures into usable results.
Composing Asynchronous Error Recovery with exceptionallyCompose()
exceptionallyCompose() is the recovery API to use when the fallback itself is asynchronous. While exceptionally() lets you replace a failure with a direct value, exceptionallyCompose() lets you return another CompletionStage. This is useful when recovery requires a retry, a backup service call, a database lookup, a cache refresh, or any operation that cannot produce a value immediately.
Consider a pipeline that loads a user profile from a primary service. If that call fails, the application may want to call a secondary service instead. With exceptionally(), returning a CompletableFuture<UserProfile> would create a nested future such as CompletableFuture<CompletableFuture<UserProfile>>, which is rarely what you want. exceptionallyCompose() avoids that nesting by flattening the fallback stage into the original pipeline, much like thenCompose() does for successful async composition.
CompletableFuture<UserProfile> profileFuture =
primaryClient.fetchProfile(userId)
.exceptionallyCompose(error ->
backupClient.fetchProfile(userId)
);
In this example, if primaryClient.fetchProfile(userId) completes normally, the fallback is never called. If it completes exceptionally, the function passed to exceptionallyCompose() receives the failure and returns a new asynchronous stage. The final profileFuture completes with the backup result if that backup succeeds, or completes exceptionally if the backup also fails.
Choosing between exceptionally() and exceptionallyCompose()
Use exceptionally() when recovery can be computed immediately. Use exceptionallyCompose() when recovery starts another asynchronous operation.
Recommended Free Tools
| API | Recovery type | Typical use case |
|---|---|---|
exceptionally() |
Returns a plain fallback value | Default value, empty list, cached constant, safe placeholder |
exceptionallyCompose() |
Returns another async stage | Retry, failover service call, async cache lookup, token refresh |
A common pattern is to inspect the exception before deciding whether recovery is allowed. Not every failure should trigger a fallback. A timeout might be recoverable through a backup endpoint, while invalid input should usually remain a failed completion. Because exceptions in CompletableFuture pipelines may be wrapped in CompletionException, production code often unwraps the cause before making that decision.
CompletableFuture<Order> orderFuture =
orderClient.fetchOrder(orderId)
.exceptionallyCompose(error -> {
Throwable cause = error instanceof CompletionException
? error.getCause()
: error;
if (cause instanceof TimeoutException) {
return replicaOrderClient.fetchOrder(orderId);
}
return CompletableFuture.failedFuture(cause);
});
CompletableFuture.failedFuture() is useful when the fallback function decides not to recover. It preserves the asynchronous shape of the pipeline while completing it exceptionally. This is cleaner than throwing from inside the recovery function, especially when branches already return CompletionStage instances.
Async variants and executor control
Java also provides async forms such as exceptionallyComposeAsync(). The async variant schedules the recovery function for asynchronous execution, either on the default executor or on an executor you provide. Supplying an explicit executor is usually better for service code because it avoids mixing blocking fallback work with the common fork-join pool.
CompletableFuture<Inventory> inventoryFuture =
inventoryClient.getInventory(sku)
.exceptionallyComposeAsync(
error -> inventoryCache.refreshAndRead(sku),
recoveryExecutor
);
Keep fallback stages bounded and observable. If the primary operation has a timeout, the recovery operation should usually have one too. Otherwise, an error path can become slower and less predictable than the normal path. Combine exceptionallyCompose() with APIs such as orTimeout(), completeOnTimeout(), or client-level timeouts so that recovery does not hang indefinitely.
- Use it for async fallback: prefer
exceptionallyCompose()when the recovery path calls another asynchronous API. - Avoid swallowing unexpected failures: recover only from known exception types and re-fail the stage for the rest.
- Preserve context: include identifiers such as request ID, user ID, or operation name in logs around the recovery path.
- Control execution: use
exceptionallyComposeAsync()with a dedicated executor when recovery may block or perform I/O.
Handling Exceptions from join(), get(), and allOf()
At some point an asynchronous pipeline often has to be observed from synchronous code, such as a controller method, a command-line entry point, or a test. In those cases, CompletableFuture exposes terminal-style methods such as join() and get(). Both wait for completion and return the result if the future completed normally, but they wrap failures differently. Understanding that wrapping is essential if you want predictable error handling at the boundary of your async workflow.
join() vs get()
join() throws an unchecked CompletionException when the future completes exceptionally. The original failure is usually available through getCause(). Because CompletionException is unchecked, join() is convenient inside stream operations, tests, and application code where you do not want to declare checked exceptions. For example, if a supplier throws IllegalStateException, calling join() on the resulting future throws CompletionException whose cause is that IllegalStateException.
Free tools Windows power users keep installed
One-click scans. No signup required.
get(), inherited from Future, throws checked exceptions: InterruptedException if the waiting thread is interrupted, and ExecutionException if the computation failed. The actual application exception is inside ExecutionException.getCause(). This makes get() more verbose, but sometimes appropriate when integrating with older APIs that already use Future or checked-exception contracts. If you catch InterruptedException, restore the interrupt flag with Thread.currentThread().interrupt() unless you have a deliberate policy for handling interruption.
| Method | Failure wrapper | Checked? | Common use |
|---|---|---|---|
join() |
CompletionException |
No | Modern CompletableFuture pipelines and tests |
get() |
ExecutionException |
Yes | Legacy Future integration and APIs requiring checked handling |
get(timeout, unit) |
ExecutionException, TimeoutException |
Yes | Synchronous boundaries that must not wait forever |
Working with allOf()
CompletableFuture.allOf() creates a new future that completes when all supplied futures complete. If every future succeeds, the combined future completes normally with Void. If one or more futures fail, the combined future completes exceptionally. Calling join() on the combined future then throws a CompletionException. The combined future does not directly provide a list of all individual errors; it only represents that the group failed. To inspect each result or failure, examine the original futures after allOf() completes.
A common pattern is to wait for the group and then collect individual outcomes. For success-only workflows, you can call allOf(futures...).thenApply(v -> futures.stream().map(CompletableFuture::join).toList()). For workflows where partial success matters, attach handle() to each individual future before calling allOf(), converting each result into a small outcome object that contains either a value or an error. That approach prevents one failed task from hiding useful results from the others.
- Unwrap at the edge: when using
join()orget(), inspectgetCause()to recover the original domain exception. - Prefer time-bounded waits: use
get(timeout, unit)or complete futures with timeout policies when blocking is unavoidable. - Avoid blocking inside async stages: calling
join()from tasks running on a limited executor can cause thread starvation. - Handle individual futures for batch work: with
allOf(), attach recovery or outcome mapping before aggregation when partial results are acceptable.
Best Practices for Robust CompletableFuture Error Handling
Robust CompletableFuture error handling starts with treating every asynchronous boundary as a place where failures can occur. A supplier passed to supplyAsync(), a transformation in thenApply(), a callback in thenCompose(), or a recovery function in exceptionally() can all throw. Once a stage completes exceptionally, dependent stages that require a successful value are skipped and the failure continues downstream until a stage handles it. Design the pipeline so each failure has a clear owner: either recover locally, convert it into a domain-specific failure, or let it propagate to a final boundary that reports it.
Prefer placing recovery close to the operation that can fail when there is a safe fallback. For example, a failed profile image lookup might recover to a default image with exceptionally(), while a failed payment authorization should usually propagate. Use handle() when the next value depends on both success and failure outcomes, and reserve whenComplete() for observation such as logging, metrics, tracing, or cleanup. Since whenComplete() does not replace the failed result unless it throws its own exception, it is a good fit for side effects but not for recovery.
Best Value
Practical guidelines
- Unwrap exceptions consistently. Calls to
join()throwCompletionException, whileget()throwsExecutionException. InspectgetCause()so application code sees the original failure rather than only the wrapper. - Use domain exceptions. Convert low-level failures such as
IOException, timeout errors, or HTTP client exceptions into meaningful exceptions likeCustomerLookupFailedExceptionorInventoryUnavailableException. - Avoid swallowing failures. Returning
nullfromexceptionally()may turn a real failure into a laterNullPointerException. Prefer explicit fallback values, optional-like results, or well-defined error objects. - Keep recovery functions small. Error handlers should not contain large business workflows. If recovery requires another asynchronous call, use
exceptionallyCompose()rather than blocking insideexceptionally(). - Add time limits. Combine error handling with
orTimeout()orcompleteOnTimeout()so a stalled dependency does not leave the workflow waiting indefinitely.
Be careful with aggregate operations such as CompletableFuture.allOf(). The aggregate future completes exceptionally if any component future fails, but it does not directly provide all individual exceptions. If several tasks may fail independently, collect their outcomes explicitly by wrapping each task with handle() and storing either the value or the error. This makes batch processing, partial success reporting, and diagnostics much clearer than relying only on the exception from the aggregate stage.
Also avoid blocking in common-pool worker threads. Calling join() inside an async stage can reduce parallelism or create thread starvation if many tasks wait on other tasks in the same executor. Compose with thenCompose() instead of blocking, and use a dedicated Executor for I/O-heavy or latency-sensitive work. For production systems, attach contextual data to logs in whenComplete(), such as request IDs, dependency names, user IDs where appropriate, and elapsed time. Clear context makes asynchronous failures far easier to trace because stack traces alone often do not show the full pipeline path.
A reliable CompletableFuture workflow usually has three layers: local fallbacks for expected failures, typed propagation for unrecoverable failures, and a final boundary that unwraps, logs, and converts the result for the caller. With that structure, async code remains predictable even when mulle services, executors, and callbacks are involved.
Frequently Asked Questions
What is the difference between exceptionally(), handle(), and whenComplete() in CompletableFuture?
exceptionally() runs only when the previous stage fails and returns a replacement value, making it useful for simple fallback behavior. handle() runs for both success and failure and can transform either result into a new value. whenComplete() also runs for both outcomes, but it is mainly for side effects such as logging or metrics because it does not normally replace the result.
Does an exception in one CompletableFuture stage stop the rest of the pipeline?
If a stage completes exceptionally, dependent stages that require its successful result are skipped and the exception is propagated downstream. A recovery stage such as exceptionally(), handle(), or exceptionallyCompose() can convert that failure back into a normal result. Once recovered, later stages continue as if the pipeline had succeeded with the recovered value.
When should I use exceptionallyCompose() instead of exceptionally()?
Use exceptionallyCompose() when your fallback itself is asynchronous, such as calling a backup service, querying another database, or retrying through another async method. exceptionally() can only return a direct replacement value, while exceptionallyCompose() returns another CompletionStage. This avoids blocking inside the error handler and keeps the pipeline fully asynchronous.
Why does join() throw CompletionException while get() throws ExecutionException?
join() wraps failures in an unchecked CompletionException, so callers are not forced to catch or declare it. get() comes from the older Future API and wraps failures in a checked ExecutionException. In both cases, the original failure is usually available through getCause().
PC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11Crashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteHow should I handle errors when using CompletableFuture.allOf()?
CompletableFuture.allOf() completes exceptionally if any of the included futures fail, but it does not directly tell you which ones failed or return their results. A common pattern is to attach error handling to each individual future before calling allOf(), or inspect each future after allOf() completes. This lets you collect partial successes, log individual failures, and decide whether to fail the whole operation or continue with fallback values.
Bottom Line
Exception handling in CompletableFuture is about choosing the right stage for the job: use exceptionally or exceptionallyCompose to recover, handle to transform success or failure, and whenComplete to observe outcomes without changing them. Once a stage fails, that failure propagates downstream until a recovery stage turns it back into a normal result.
For reliable async workflows, keep error handling explicit, avoid swallowing exceptions accidentally, and place recovery close to the operation that can fail. The next step is to review your async pipeline and decide where each failure should be logged, retried, converted, or allowed to propagate to the caller.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

