Create your own
Lesson illustration

Concurrent I/O and Failure Handling with CompletableFuture

Hello. In the previous lesson, you selected concurrent collections by identifying the shared-state workload and the atomic operation required. This lesson shifts to a different concurrency problem: overlapping independent remote calls rather than coordinating access to shared memory.

In a backend service, a product-detail endpoint may need catalog data, price, inventory, and recommendations from separate sources. If one call does not need another call’s result, making them wait serially adds unnecessary latency. The challenge is to start such work concurrently without losing failures, accidentally treating an HTTP error as success, or hiding an outage behind an unsafe fallback.

By the end of this lesson, you should be able to compose independent I/O operations with CompletableFuture, combine their results, and make deliberate decisions about which failures must propagate and which may recover.


Model dependencies before choosing an API

Consider a product page that requires three pieces of data:

  • Catalog returns product name and description.
  • Pricing returns the current price.
  • Inventory returns availability.

These calls are independent: each only needs productId. Starting catalog first and waiting for it before starting pricing and inventory is therefore a design mistake.

// Serial: each network wait delays the start of the next one.
Product product = catalogClient.fetch(productId).join();
Price price = pricingClient.fetch(productId).join();
Availability availability = inventoryClient.fetch(productId).join();

If the calls take , , and milliseconds respectively, serial execution is roughly milliseconds before assembly. When initiated concurrently, the response time is closer to the slowest successful dependency, plus small coordination overhead. Actual latency still depends on network conditions and downstream capacity, but the dependency graph no longer imposes needless waiting.

The equivalent CompletableFuture version starts all three operations before combining them:

CompletableFuture<Product> productFuture = catalogClient.fetch(productId);
CompletableFuture<Price> priceFuture = pricingClient.fetch(productId);
CompletableFuture<Availability> availabilityFuture =
        inventoryClient.fetch(productId);

return productFuture
        .thenCombine(priceFuture, ProductAndPrice::new)
        .thenCombine(availabilityFuture,
                (productAndPrice, availability) ->
                        new ProductView(
                                productAndPrice.product(),
                                productAndPrice.price(),
                                availability));

thenCombine does not itself start the remote calls. The calls are already in progress because fetch(...) created and initiated each future. thenCombine declares what to do once both required results are available.

Use these operations based on dependency shape:

SituationAppropriate operation
Transform one completed value in memorythenApply
The next asynchronous call needs the previous resultthenCompose
Two independently started operations both produce required valuesthenCombine
A dynamic collection of independently started operations must all completeallOf
Provide a valid fallback for one failed stageexceptionally
Inspect success or failure without changing the outcomewhenComplete

The distinction between thenApply and thenCompose is particularly important:

// The shipping call needs the order returned by the first call.
CompletableFuture<ShippingQuote> quoteFuture =
        orderClient.fetch(orderId)
                .thenCompose(shippingClient::quoteFor);

Here, shipping cannot begin until the order is available. It is a sequential dependency, even though both calls are asynchronous.

Take a few minutes with this focused walkthrough before continuing.

A Guide To CompletableFuture in Java with Examples | Asynchronous Operations in Java | Geekific

Watch “A Guide To CompletableFuture in Java with Examples” by Geekific for a visual explanation of callbacks, dependent versus independent futures, and exception propagation.

Start with callbacks to distinguish result transformation from terminal actions. Then watch composition for thenCompose versus thenCombine, followed by multiple futures for allOf. Finish with failure handling; focus on the fact that a failure skips ordinary downstream stages until it is explicitly recovered.


Start genuine asynchronous I/O

CompletableFuture is an orchestration API. It does not magically make a blocking HTTP client non-blocking.

For example, Java’s HttpClient.sendAsync initiates an asynchronous HTTP exchange and immediately returns a future representing the eventual response. This is preferable to placing a blocking call inside supplyAsync when an asynchronous client is available.

HttpClient (Java SE 17 & JDK 17)

Read the Oracle Java API documentation for HttpClient. Although this page is from Java 17, the sendAsync concepts and APIs used here apply directly to Java 21.

At the top of the class description, read the asynchronous overview, including the asynchronous example. Then open the sendAsync method details and read the exceptional-completion rules. Notice the distinction between receiving an HTTP response and a transport-level I/O failure.

A small asynchronous HTTP client method might look like this:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;

final class PricingClient {

    private final HttpClient httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(1))
            .build();

    CompletableFuture<String> fetchPriceBody(String productId) {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://pricing.internal/prices/" + productId))
                .timeout(Duration.ofSeconds(2))
                .GET()
                .build();

        return httpClient.sendAsync(
                        request,
                        HttpResponse.BodyHandlers.ofString())
                .thenApply(response -> {
                    if (response.statusCode() / 100 != 2) {
                        throw new DownstreamHttpException(
                                "Pricing returned HTTP " + response.statusCode());
                    }

                    return response.body();
                });
    }
}

There are two important failure categories here:

  1. Transport failure
    DNS failure, connection refusal, TLS failure, or a request timeout can cause sendAsync to complete exceptionally.

  2. Application-level HTTP failure
    A received 404, 429, or 500 response is still an HttpResponse. The HTTP exchange succeeded technically. Your client must inspect the status code and convert unacceptable responses into an application exception.

Without the statusCode() check, a pricing service returning 500 might be treated as a successful completion containing an error body. That is a common integration bug.

A note on supplyAsync

Sometimes a library exposes only a blocking API. This is possible:

CompletableFuture<Customer> customerFuture =
        CompletableFuture.supplyAsync(
                () -> blockingCustomerClient.fetch(customerId),
                ioExecutor);

It creates concurrency, but a thread remains blocked while the remote call waits. Do not use the default common pool casually for blocking I/O; it is shared infrastructure and can become starved. In the next lesson, you will configure and compare executors designed for this choice, including virtual-thread-per-task execution.

Also avoid this pattern:

CompletableFuture<CompletableFuture<String>> nested =
        CompletableFuture.supplyAsync(() -> pricingClient.fetchPriceBody(productId));

The outer task merely schedules the creation of an already asynchronous operation, leaving you with an unwanted nested future. Call the asynchronous client directly.


Failure propagation is part of the API contract

A CompletableFuture has two broad outcomes:

  • normal completion, carrying a value;
  • exceptional completion, carrying a failure.

If a stage fails, ordinary success-only stages such as thenApply, thenCompose, and thenCombine downstream are skipped. The returned future remains failed unless a handler intentionally changes it.

A failed `supplyAsync` stage causes its first `thenApply` stage to be skipped; `exceptionally` supplies a recovery value, after which later stages can run normally.

The diagram captures a key design consequence: exceptionally is not merely logging. It recovers the chain by replacing failure with a normal value.

Choose recovery policy per dependency

Not every downstream call should have the same failure policy.

For a product page:

  • Catalog, pricing, and inventory may be required. Returning a page without a price or with stale inventory can be misleading or unsafe.
  • Recommendations may be optional. An empty recommendation list can be a valid degraded response.
CompletableFuture<List<Recommendation>> recommendationsFuture =
        recommendationClient.fetchFor(productId)
                .exceptionally(error -> {
                    log.warn("Recommendations unavailable for product {}: {}",
                            productId, rootCause(error).toString());
                    return List.of();
                });

The fallback is correct only because an empty list has an agreed business meaning: “recommendations temporarily unavailable.” It must also be observable through logs and, in production, metrics.

Do not use a generic fallback such as this:

.exceptionally(error -> null)

It conceals the real failure, makes downstream behavior ambiguous, and commonly creates a later NullPointerException that loses the useful root cause.

For required dependencies, allow failure to propagate:

CompletableFuture<ProductView> requiredViewFuture =
        productFuture
                .thenCombine(priceFuture, ProductAndPrice::new)
                .thenCombine(availabilityFuture,
                        (productAndPrice, availability) ->
                                new ProductView(
                                        productAndPrice.product(),
                                        productAndPrice.price(),
                                        availability));

If catalog, price, or inventory completes exceptionally, this aggregate future completes exceptionally too. That outcome can later be mapped at an API boundary to a consistent error response; you will implement that Spring mechanism in the REST API module.

exceptionally, handle, and whenComplete

These methods all receive failure information, but they have different semantics:

MethodRuns on success?Can change failure into success?Typical purpose
exceptionally(error)NoYesRecover with a valid fallback
handle((value, error) ...)YesYesProduce a new result from either outcome
whenComplete((value, error) ...)YesNo, if observer succeedsLogging, metrics, tracing, cleanup

Use whenComplete for observation when a required dependency must still fail:

CompletableFuture<ProductView> observedFuture =
        requiredViewFuture.whenComplete((view, error) -> {
            if (error != null) {
                log.warn("Product page assembly failed for {}: {}",
                        productId, rootCause(error).toString());
            }
        });

Because whenComplete does not provide a replacement value, observedFuture remains exceptional when requiredViewFuture fails. That is usually the correct behavior for required data.

Asynchronous failures are often wrapped in CompletionException; get() uses the checked ExecutionException wrapper instead. When logging or mapping a failure, inspect the cause rather than reporting only the wrapper:

private static Throwable rootCause(Throwable error) {
    if (error.getCause() != null) {
        return error.getCause();
    }
    return error;
}

In production code, preserve the original exception as the cause when translating it into a domain-specific exception. The error type, downstream name, operation, and request correlation ID are all more useful than a vague message such as “future failed.”


Coordinate a variable number of calls with allOf

thenCombine is clearest for two or three known operations. For a dynamic number of calls, such as collecting offers from several suppliers, use allOf.

List<CompletableFuture<Offer>> offerFutures = supplierIds.stream()
        .map(supplierId -> supplierClient.fetchOffer(supplierId, productId))
        .toList();

return CompletableFuture
        .allOf(offerFutures.toArray(CompletableFuture[]::new))
        .thenApply(ignored -> offerFutures.stream()
                .map(CompletableFuture::join)
                .toList());

allOf produces CompletableFuture<Void> because it coordinates completion but does not know how to construct a typed aggregate. The thenApply stage collects values from the original futures.

Using join() is safe inside this thenApply because it runs only after allOf has completed normally. If any input future fails, allOf completes exceptionally and this collection step is skipped.

Do not use join() earlier to “retrieve” a future’s value:

// Defeats asynchronous composition by blocking the current thread.
Offer offer = supplierClient.fetchOffer(supplierId, productId).join();

Similarly, allOf does not provide transactional behavior. If three independent write operations are started and one fails, the other two may already have succeeded. CompletableFuture coordinates in-memory completion states; it does not roll back remote side effects. Distributed compensation and sagas are later topics in the course.


Time limits and practical review rules

Remote calls need time limits. The HttpRequest.timeout(...) in the earlier client method constrains the HTTP exchange itself. At the future level, Java also offers orTimeout:

CompletableFuture<Price> priceFuture =
        pricingClient.fetchPrice(productId)
                .orTimeout(800, TimeUnit.MILLISECONDS);

This completes that stage exceptionally with a timeout if it is not completed in time. It does not guarantee that the remote operation has stopped; cancellation and resource cleanup depend on the underlying client. For now, establish the habit of setting timeouts. Later modules will derive them from latency budgets and combine them with safe retries and circuit breakers.

When reviewing CompletableFuture code in an interview or a pull request, ask:

  1. Are the remote operations genuinely independent? Start independent calls before combining them.
  2. Does the client initiate asynchronous I/O, or is blocking I/O being offloaded? If blocking, which executor owns those threads?
  3. Are non-2xx HTTP responses converted into meaningful failures?
  4. Which dependencies are required, and which have an explicitly valid fallback?
  5. Does logging observe failures without unintentionally recovering them?
  6. Are timeouts configured at an appropriate boundary?
  7. Is join() used only after completion is guaranteed, or at a deliberate application boundary?

Key takeaways

  • Start independent I/O operations first, then combine their futures with thenCombine or allOf.
  • Use thenCompose only when the next asynchronous operation depends on the previous result.
  • sendAsync can fail for transport reasons, while an HTTP 4xx or 5xx response must be checked and translated deliberately.
  • Failures skip ordinary downstream success stages until a recovery method handles them.
  • Use exceptionally only for a business-valid fallback; use whenComplete to log or measure while preserving failure.
  • allOf coordinates completion but does not collect typed results or roll back partially completed remote writes.
  • Avoid blocking join() in the middle of an asynchronous pipeline, and set timeouts for remote work.

Next, you will configure and compare a bounded platform-thread executor with a virtual-thread-per-task executor, which determines how safely a service can run blocking backend workloads under load.

Can't find a good explanation? Sign up and we'll make it for you

Sign up