Non-Blocking Java Pipelines with CompletableFuture
Part 19 of 19 in Functional Java Unleashed
Non-Blocking Java Pipelines with CompletableFuture
Java’s CompletableFuture (since JDK 8) turns asynchronous programming from nested callbacks into a chain of declarative steps. Each combinator — thenApply, thenCompose, handle, orTimeout — reads like a specification of what should happen, not how to juggle futures manually.
We’ll walk through four patterns that cover the common ground: chaining transforms, error recovery, deadline enforcement, and parallel composition.
Declarative Pipelines
The most natural fit is transforming data through stages. With raw Future, you call get() (blocking) or register a callback (which nests quickly). CompletableFuture chains methods that return the next stage in the pipeline:
CompletableFuture<String> pipeline = fetchUserData("u42")
.thenApply(json -> {
System.out.println(" [1] Got raw JSON: " + json);
return json;
})
.thenCompose(_json -> enrichData(_json))
.thenApply(enriched -> enriched.toUpperCase());
String result = pipeline.get();
thenApply transforms the result inline and returns a new stage. thenCompose flattens nested futures — use it when your transform itself returns a CompletableFuture rather than a plain value. The pipeline is lazy; nothing executes until you call get(), join(), or register another downstream step.
Here’s what that looks like with simulated network latency:
The chain took 359ms — two tasks chained sequentially via thenCompose, each doing its own async work. The result shows the enriched JSON with a grade field added by the second stage, all wired together without a single explicit callback.
Error Handling
With raw Future, exceptions get wrapped in ExecutionException and you need try-catch around every get() call. CompletableFuture pushes error handling into the pipeline:
CompletableFuture<String> recover = fetchFromService("fail")
.exceptionally(ex -> {
System.err.println(" ERROR caught: " + ex.getMessage());
return "{\"service\":\"fallback\",\"data\":[]}";
});
CompletableFuture<String> handled = fetchFromService("ok")
.handle((result, ex) -> {
if (ex != null)
return "[EXCEPTION] " + ex.getMessage();
return "[OK] " + result;
});
exceptionally catches errors and provides a recovery value — the pipeline continues normally downstream. handle is stricter: it always runs, whether the upstream succeeded or failed, receiving both the result and exception so you can branch:
The exceptionally example caught the “Connection refused: fail” error and returned a fallback JSON object — downstream steps see a normal value. The handle variant shows the dual-path approach: one handler for both success (“[OK]”) and failure (“[EXCEPTION]”). Compare that to the old way at the bottom, where Future.get() requires a try-catch wrapper with ExecutionException.getCause() unwrapping.
A word of caution: exceptionally converts the exceptional state to normal. If you want errors to propagate to the next stage, don’t return a value from your handler — throw again, or use whenComplete which preserves the original outcome.
Timeout Handling
Two approaches exist for deadlines, and they serve different purposes:
// Fails fast with an exception
String result = slowTask.orTimeout(200, TimeUnit.MILLISECONDS).join();
// Degrades gracefully with a fallback value
String result = slowTask.completeOnTimeout("[fallback]", 200, TimeUnit.MILLISECONDS).join();
orTimeout completes the future exceptionally (with TimeoutException) if the deadline passes. It’s useful when failure is the correct response to slowness. completeOnTimeout fills in a default value instead — graceful degradation for non-critical paths.
When you combine them:
The first example shows orTimeout: a 500ms task with a 200ms deadline completes exceptionally at ~201ms, throwing TimeoutException. The second uses completeOnTimeout — same timing, but the pipeline gets the fallback value instead of crashing.
The third example demonstrates that fast tasks don’t trigger timeouts at all: a 50ms task with a 200ms deadline completes normally in ~51ms. Timeouts only fire when the operation exceeds its window.
The fourth example combines multiple futures, each with its own timeout. Service A (100ms delay) and B (80ms delay) complete within their 300ms deadline. Service C (400ms delay) times out. allOf waits for all three to reach a terminal state — the wall-clock time (~304ms) is bounded by the longest timeout, not the sum of individual delays.
Combining Futures
When multiple async operations must interact, three methods cover every case:
// Wait for ALL (parallel fan-in)
CompletableFuture<String> all = CompletableFuture.allOf(f1, f2, f3);
// Return on FIRST completion (race)
Object winner = CompletableFuture.anyOf(f1, f2, f3);
// Merge exactly two results
String merged = f1.thenCombine(f2, (a, b) -> a + " | " + b);
allOf returns a CompletableFuture<Void> that completes when all inputs finish. You use thenApply to collect their individual results via .join(). anyOf returns the result of whichever future wins — useful for caching strategies where you want the fastest response.
// Race between cache, database, and external API
String fastest = CompletableFuture.anyOf(
queryCache("user_42"),
queryDatabase("user_42"),
queryExternalAPI("user_42")
).thenApply(r -> "Fastest: " + r).join();
thenCombine merges two futures with a custom function:
String merged = queryCache("item_7")
.thenCombine(queryDatabase("item_7"),
(cache, db) -> "[merge] cache=" + cache + " | db=" + db)
.join();
And whenComplete adds side effects without changing the outcome:
f.whenComplete((result, ex) -> {
if (ex != null) log.error(ex);
else log.info(result);
}); // f's result flows downstream unchanged
allOf completed both cache and database lookups in ~150ms — bounded by the slower task (database), proving parallel execution. anyOf returned the cache result at ~51ms, one-sixth of the API deadline. thenCombine similarly merged two results in ~150ms with a custom transform. The final whenComplete example demonstrates that side effects fire regardless of outcome — it’s logging, not transformation.
Takeaway
The mental model is simple: every CompletableFuture combinator returns another CompletableFuture. You’re building a directed acyclic graph of stages where each node transforms or observes its predecessor’s result. No callbacks to nest, no Future.get() to block on, and no try-catch pyramids — just a chain that either completes normally or exceptionally, with explicit recovery at the point of failure.
Reach for this when your application calls multiple external services, needs deadline enforcement, or requires error recovery without blocking threads. The cost is learning which combinator fits which situation (that’s what these four examples cover), but once you internalize the API surface — thenApply transforms, thenCompose flattens, handle branches, exceptionally recovers, orTimeout enforces deadlines, allOf/anyOf fan in/out — the rest is just composition.