Hello. In the previous lesson, you configured a bounded ExecutorService so that background work has explicit limits, overload behavior, and a graceful shutdown policy. That executor is the controlled environment in which many asynchronous tasks will run.
This lesson moves one level higher: instead of submitting isolated tasks and later blocking on a Future, you will build asynchronous pipelines with CompletableFuture. You will learn to distinguish dependent work from independent work, choose the correct composition method, and make failures explicit rather than accidentally hiding them. Plan for roughly 40 minutes.
1. From a task handle to an asynchronous pipeline
A traditional Future<T> tells you that a task may eventually produce a T. Its main retrieval API, get(), blocks until that happens. That is adequate for a batch worker waiting for a result, but it is awkward for a backend request that must coordinate several remote calls without tying up a request thread merely waiting.
CompletableFuture<T> solves two distinct problems:
- It is a
Future<T>, so it can represent an eventual value, cancellation, or failure. - It implements
CompletionStage<T>, so you can declare what should happen after it completes: transform its value, launch dependent work, combine it with another result, or recover from an error.

The crucial distinction is this:
A
CompletableFuturepipeline can avoid blocking while it waits for prior stages. It does not magically make blocking database or HTTP calls non-blocking.
If you wrap a synchronous HTTP call in supplyAsync, that call still occupies a worker thread while it waits on the network. The benefit is that it occupies a deliberately bounded I/O executor, rather than the application’s request-handling thread.
Watch this concise overview before moving into the backend examples.
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 overview of why CompletableFuture exists and how callback chains, composition, combination, and exception recovery fit together.
Start with the motivation to contrast Future with CompletableFuture. Then watch creating futures for runAsync versus supplyAsync; simple continuations for thenApply, thenAccept, and thenRun; and composition and combination for the difference between dependent and independent work. Finish with coordinating many calls and failure propagation. Focus on the shape of the dependency when choosing an API, not just on method names.
Creating work with a controlled executor
You saw why using an explicit bounded executor matters in the previous lesson. Carry that policy into CompletableFuture code.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
public final class ProductCatalogService {
private final ExecutorService remoteCallExecutor;
private final CatalogClient catalogClient;
public ProductCatalogService(
ExecutorService remoteCallExecutor,
CatalogClient catalogClient
) {
this.remoteCallExecutor = remoteCallExecutor;
this.catalogClient = catalogClient;
}
public CompletableFuture<Product> loadProduct(String productId) {
return CompletableFuture.supplyAsync(
() -> catalogClient.fetchProduct(productId),
remoteCallExecutor
);
}
}
Use supplyAsync when the work produces a value. Use runAsync only when the work is truly side-effect-only and the next stage does not need a result.
Avoid relying accidentally on the common fork-join pool:
CompletableFuture.supplyAsync(() -> catalogClient.fetchProduct(productId));
Without the executor argument, Java normally uses a shared common pool. In a backend service, that makes resource ownership, saturation, observability, and isolation less clear. A slow catalog call should not compete unpredictably with unrelated parallel work elsewhere in the process.
2. Choose the operation from the dependency shape
The most important design question is not “Which CompletableFuture method do I remember?” It is:
Does the next operation need the previous result? Does it itself return an asynchronous result? Or are two operations independent?
Transform an available value with thenApply
Use thenApply when a completed value can be transformed synchronously and cheaply.
CompletableFuture<ProductSummary> summaryFuture =
productService.loadProduct(productId)
.thenApply(ProductSummary::from);
Here, ProductSummary::from does not call a remote service and does not return another future. It is just a local mapping from one in-memory representation to another.
thenAccept is similar but consumes the result and produces CompletableFuture<Void>. It is useful for a terminal side effect, such as forwarding a completed result to a caller-managed callback. thenRun receives no prior result at all; it simply runs after successful completion.
For service logic, prefer retaining values in the pipeline where possible. A chain that ends early in logging or mutation is harder to compose and test.
Chain dependent asynchronous work with thenCompose
Now consider an operation that cannot begin until another remote call finishes:
- Load the customer.
- Use the customer’s delivery address to request a shipping quote.
The shipping call is both dependent on the customer and asynchronous itself.
public CompletableFuture<ShippingQuote> quoteShipping(
String customerId,
Cart cart
) {
return customerClient.fetchCustomer(customerId)
.thenCompose(customer ->
shippingClient.quote(customer.deliveryAddress(), cart)
);
}
The key detail is the return type of the callback:
customer -> shippingClient.quote(customer.deliveryAddress(), cart)
That callback returns CompletableFuture<ShippingQuote>. thenCompose flattens it into one:
CompletableFuture<ShippingQuote>
Using thenApply here would create an awkward nested result:
CompletableFuture<CompletableFuture<ShippingQuote>>
A practical mental model is:
| Situation | Appropriate method | Result |
|---|---|---|
| Transform into locally | thenApply | CompletableFuture<U> |
| Start async work that transforms into a future | thenCompose | CompletableFuture<U> |
| Consume a successful without producing a new value | thenAccept | CompletableFuture<Void> |
In interview language: thenApply maps; thenCompose flat-maps asynchronous dependent work.
Combine independent results with thenCombine
Suppose an order-details page needs both customer details and current loyalty offers. Neither request requires the other’s result, so start them independently.
public CompletableFuture<OrderPageData> loadOrderPage(
String customerId,
String orderId
) {
CompletableFuture<Customer> customerFuture =
customerClient.fetchCustomer(customerId);
CompletableFuture<Order> orderFuture =
orderClient.fetchOrder(orderId);
return customerFuture.thenCombine(
orderFuture,
(customer, order) -> OrderPageData.of(customer, order)
);
}
The requests can overlap in time. The combining function runs only after both have completed successfully.
Do not write the same design as a sequential chain:
return customerClient.fetchCustomer(customerId)
.thenCompose(customer ->
orderClient.fetchOrder(orderId)
.thenApply(order -> OrderPageData.of(customer, order))
);
It may produce the same final result, but it unnecessarily delays the order request until after the customer request. At backend scale, recognizing needless serialization is a meaningful performance skill.
Coordinate several independent futures with allOf
For a variable-sized group of independent work, use allOf. It creates a future that completes once all supplied futures complete.
CompletableFuture<Price> usPrice = pricingClient.fetch("US", productId);
CompletableFuture<Price> euPrice = pricingClient.fetch("EU", productId);
CompletableFuture<Price> apacPrice = pricingClient.fetch("APAC", productId);
CompletableFuture<List<Price>> prices =
CompletableFuture.allOf(usPrice, euPrice, apacPrice)
.thenApply(ignored -> List.of(
usPrice.join(),
euPrice.join(),
apacPrice.join()
));
allOf has the type CompletableFuture<Void> because it coordinates completion rather than assembling a typed collection for you.
The calls to join() inside the thenApply stage are safe only because that stage runs after allOf has completed normally. At that point, the individual results are already available; the joins do not introduce a wait.
If any component future fails, the normal thenApply stage is skipped and the combined future completes exceptionally. You should decide whether every sub-result is required or whether a particular dependency has a valid fallback before reaching allOf.
3. Continuations, threads, and the meaning of “async”
A future pipeline describes dependency order. It does not, by itself, promise that each stage gets a brand-new thread.
For a continuation without the Async suffix:
future.thenApply(this::toSummary);
Java may execute the continuation in the thread that completes the preceding stage. If the future was already complete when you attach the continuation, the calling thread may execute it immediately.
That is usually desirable for short operations such as:
- DTO mapping;
- validating an already-fetched result;
- combining two small values;
- recording a lightweight metric.
It is a poor place for expensive CPU work or a blocking call. If a continuation needs deliberate scheduling, use an async variant with the appropriate executor:
CompletableFuture<RecommendationModel> modelFuture =
productFuture.thenApplyAsync(
recommendationEngine::calculate,
cpuExecutor
);
This is a scheduling decision, not a decoration. Adding Async everywhere can create extra queueing and context-switch overhead while obscuring which resource pool owns the work.
A sensible backend rule is:
- use ordinary
thenApply,thenCompose, andthenCombinefor small, local continuation logic; - use an explicit executor when starting blocking I/O or substantial CPU work;
- avoid the common pool for service-owned production work unless you have deliberately decided it is appropriate.
For APIs that consumers should only compose, returning CompletionStage<T> communicates a useful boundary:
public CompletionStage<OrderPageData> loadOrderPage(
String customerId,
String orderId
) {
// Implementation may use CompletableFuture internally.
}
The caller can attach stages, but it is not encouraged to complete or otherwise control the concrete future.
4. Failure is also an asynchronous result
A future has more than “not done” and “done” states. It may complete:
- normally, with a value;
- exceptionally, with a failure;
- through cancellation.
If a stage fails, ordinary success continuations downstream are skipped. For example:
CompletableFuture<String> result =
CompletableFuture.supplyAsync(() -> {
throw new IllegalStateException("Catalog unavailable");
}, remoteCallExecutor)
.thenApply(String::trim)
.thenApply(String::toUpperCase);
Neither thenApply runs. The final future completes exceptionally with the original failure represented in its completion state.
This is a major improvement over manually calling get() after every step, but it requires intentional recovery design. Read the following focused sections for the precise differences among the error-handling methods.
Working with Exceptions in Java CompletableFuture
Read Baeldung’s “Working with Exceptions in Java CompletableFuture” to distinguish recovery (handle, exceptionally) from observation (whenComplete). This distinction prevents a common production bug: logging an exception and mistakenly assuming it was handled.
Read Sections 3, 4, and 5: “handle()”, “exceptionally()”, and “whenComplete()”. In the first section, focus on handling both outcomes: both the value and exception arguments can be absent in their respective cases. In Section 4, read the recovery behavior, especially why a recovered exception does not reach a later exceptionally stage. In Section 5, focus on the propagation rule: whenComplete observes completion but does not turn a failure into success.
exceptionally: recover only from failure
Use exceptionally when there is a valid fallback for a failed stage.
CompletableFuture<InventoryStatus> inventoryFuture =
inventoryClient.fetchStatus(productId)
.exceptionally(error -> {
log.warn("Inventory lookup failed for {}", productId, error);
return InventoryStatus.unknown(productId);
});
If inventory lookup succeeds, the callback does not run. If it fails, the future becomes normally completed with InventoryStatus.unknown(productId).
That fallback may be appropriate for a product browsing page: the page can show “availability currently unavailable.” It would be inappropriate for an order-confirmation workflow, where silently treating unknown inventory as available could oversell stock.
Recovery must be a business decision, not merely a way to make errors disappear.
handle: transform either outcome
Use handle when the next stage needs a value regardless of success or failure.
CompletableFuture<LookupResult<Product>> resultFuture =
productService.loadProduct(productId)
.handle((product, error) -> {
if (error == null) {
return LookupResult.found(product);
}
return LookupResult.unavailable(
unwrapCompletionException(error)
);
});
Unlike exceptionally, handle always executes. It receives:
- the result and a
nullerror after normal completion; - a
nullresult and the exception after exceptional completion.
Because handle transforms the outcome into a normal LookupResult, downstream stages will proceed. That can be exactly right for a typed result model, but dangerous if it converts unexpected infrastructure failures into misleading successful-looking data.
whenComplete: observe without recovering
Use whenComplete for completion-side effects such as logging, tracing, and metrics.
CompletableFuture<OrderPageData> pageFuture =
loadOrderPage(customerId, orderId)
.whenComplete((page, error) -> {
if (error != null) {
log.error("Order page request failed", error);
} else {
metrics.increment("order.page.success");
}
});
whenComplete does not recover from the failure. If loadOrderPage fails, pageFuture still fails after the logging action.
That makes it a good choice for observability. It is not a substitute for exceptionally or handle.
| Method | Runs on success? | Runs on failure? | Can recover with a value? | Typical purpose |
|---|---|---|---|---|
exceptionally | No | Yes | Yes | Failure-specific fallback |
handle | Yes | Yes | Yes | Convert either outcome into a new result |
whenComplete | Yes | Yes | No, normally preserves prior outcome | Logging, tracing, metrics, cleanup |
5. A production-shaped fan-out and fallback example
Consider a product page. The product itself is mandatory. Inventory is useful but may be temporarily unavailable.
public CompletableFuture<ProductPage> loadProductPage(String productId) {
CompletableFuture<Product> productFuture =
CompletableFuture.supplyAsync(
() -> catalogClient.fetchProduct(productId),
remoteCallExecutor
);
CompletableFuture<InventoryStatus> inventoryFuture =
CompletableFuture.supplyAsync(
() -> inventoryClient.fetchStatus(productId),
remoteCallExecutor
).exceptionally(error -> {
log.warn("Inventory dependency failed for product {}", productId, error);
return InventoryStatus.unknown(productId);
});
return productFuture
.thenCombine(
inventoryFuture,
(product, inventory) -> ProductPage.of(product, inventory)
)
.whenComplete((page, error) -> {
if (error != null) {
log.error("Could not construct product page for {}", productId, error);
}
});
}
This chain has a deliberate operational policy:
- Catalog and inventory calls start independently.
- Both calls run in the bounded executor from the previous lesson.
- Inventory failure has a defined degraded mode.
- Catalog failure is not hidden; without the product, a valid page cannot be built.
- The final
whenCompleterecords success or failure but leaves the outcome intact for the outer layer to handle.
A web adapter can return this asynchronous result rather than immediately calling join(). In frameworks that support asynchronous controller return values, this allows the request thread to be released while the pipeline progresses. If your outermost boundary truly must wait, make that blocking explicit and bounded; do not bury join() inside service methods.
join() versus get()
Both retrieve a completed result and block if necessary:
get()throws checkedInterruptedExceptionandExecutionException.join()throws uncheckedCompletionExceptionwhen the future failed.
Inside a chain, prefer composition instead of either method. At a clearly defined synchronous boundary, join() is often convenient, but its exception should be unwrapped and mapped intentionally.
public static Throwable unwrapCompletionException(Throwable error) {
Throwable current = error;
while ((current instanceof java.util.concurrent.CompletionException
|| current instanceof java.util.concurrent.ExecutionException)
&& current.getCause() != null) {
current = current.getCause();
}
return current;
}
Do not expose a generic CompletionException directly as an API response. Its underlying cause may be a timeout, a validation exception, an authorization error, or an unavailable dependency; those cases require different handling and observability.
6. Timeouts do not replace cancellation or network limits
A future that never completes is a resource and latency risk. Java provides useful timeout operations:
CompletableFuture<Product> productFuture =
CompletableFuture.supplyAsync(
() -> catalogClient.fetchProduct(productId),
remoteCallExecutor
)
.orTimeout(300, java.util.concurrent.TimeUnit.MILLISECONDS);
After 300 milliseconds, the future completes exceptionally with a timeout failure if it has not already completed.
For a safe default result, Java also provides completeOnTimeout:
CompletableFuture<InventoryStatus> inventoryFuture =
CompletableFuture.supplyAsync(
() -> inventoryClient.fetchStatus(productId),
remoteCallExecutor
)
.completeOnTimeout(
InventoryStatus.unknown(productId),
150,
java.util.concurrent.TimeUnit.MILLISECONDS
);
These are useful orchestration-level policies, but they do not reliably stop the underlying blocking operation. A synchronous HTTP call may continue consuming an executor thread after the future has timed out.
For production remote calls, use both:
- connection and request timeouts in the HTTP or database client;
- a future timeout to bound how long the broader workflow waits;
- bounded executor capacity to limit in-flight blocking work;
- a fallback only when the business behavior is safe.
This layered approach will matter later when you design timeouts, retries, circuit breakers, and load shedding across services.
7. An interview-ready explanation
A strong explanation is more than “I use CompletableFuture for multithreading.” Be precise:
“I use
thenApplyfor inexpensive synchronous transformations,thenComposewhen the next asynchronous call depends on a prior result, andthenCombineorallOffor independent calls that can run concurrently. I pass an application-owned bounded executor for blocking work rather than relying on the common pool. Failures propagate through normal stages, so I useexceptionallyonly for business-valid fallbacks,handlewhen I need a typed outcome from either path, andwhenCompletefor logging or metrics without swallowing the error. I avoid blocking withjoin()inside service logic and set both client-level and workflow-level timeouts.”
That answer demonstrates concurrency mechanics, backend resource ownership, latency awareness, and failure semantics.
Key takeaways
CompletableFutureis both aFutureand aCompletionStage: it represents an eventual result and supports declarative continuations.- Use
supplyAsyncfor value-producing work and pass an explicit executor for service-owned blocking or expensive tasks. - Use
thenApplyfor local transformations,thenComposefor dependent asynchronous work, andthenCombineorallOffor independent work. - A continuation without
Asyncmay run in the thread that completes the prior stage; keep such continuations short and non-blocking. - Failure skips normal continuation stages until you explicitly recover or transform it.
exceptionallyrecovers only from failure;handletransforms either outcome;whenCompleteobserves completion while preserving the original success or failure.- A future timeout bounds the workflow’s wait, but does not replace HTTP/database timeouts or reliably stop underlying blocking work.
- Avoid
join()andget()in the middle of service pipelines; compose instead.
Next, you will shift from designing concurrency behavior to diagnosing runtime behavior: using JVM profiling evidence to locate CPU, allocation, and memory bottlenecks.
Can't find a good explanation? Sign up and we'll make it for you
Sign up