Integrations spend most of their time waiting: for an API, a database, a file transfer. If you call three services one after another and each takes 300 ms, you wait ~900 ms. Called at the same time, you wait about as long as the slowest one. CompletableFuture (Java 8+) makes that easy, using the lambdas you already know.
Starting work in the background
ExecutorService pool = Executors.newFixedThreadPool(4);
CompletableFuture<String> customer = CompletableFuture.supplyAsync(() -> fetchCustomer("C1"), pool);
CompletableFuture<Double> limit = CompletableFuture.supplyAsync(() -> fetchCreditLimit("C1"), pool);
CompletableFuture<Double> balance = CompletableFuture.supplyAsync(() -> fetchOpenBalance("C1"), pool);
supplyAsync returns immediately with a future — a promise of a result. The three calls now run in parallel.
Tip: Pass your own ExecutorService for I/O work. Without one, tasks run on the shared ForkJoin common pool, which is sized for CPU work and can be starved by slow I/O. (On Java 21, virtual threads — Executors.newVirtualThreadPerTaskExecutor() — are an excellent choice for I/O-bound tasks.)
Transforming and combining
CompletableFuture<Double> available = limit.thenCombine(balance, (l, b) -> l - b);
String summary = customer
.thenCombine(available, (c, a) -> c + " can spend " + a)
.thenApply(String::toUpperCase)
.join(); // wait for the final result
| Method | Use it to |
|---|---|
thenApply(f) | transform the result (like map) |
thenCompose(f) | chain another async call that returns a future (like flatMap) |
thenCombine(other, f) | combine two independent futures |
thenAccept(c) | consume the result |
allOf(...) / anyOf(...) | wait for all / the first of many |
join() / get() | block until the result is ready |
Waiting for many
List<CompletableFuture<String>> calls = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> fetchCustomer(id), pool))
.toList();
CompletableFuture.allOf(calls.toArray(new CompletableFuture[0])).join();
calls.forEach(f -> System.out.println(f.join()));
Start them all first (the toList() forces every supplyAsync to run), then wait.
Errors without try/catch everywhere
CompletableFuture<String> safe = CompletableFuture
.supplyAsync(() -> callFlakyApi(), pool)
.exceptionally(ex -> "fallback"); // recover with a default
CompletableFuture<String> logged = CompletableFuture
.supplyAsync(() -> callFlakyApi(), pool)
.handle((result, ex) -> ex == null ? result : "failed: " + ex.getMessage());
An exception inside a stage skips the following transformations until an exceptionally or handle deals with it — much like a catch further down. join() on a failed future throws CompletionException wrapping the original.
Timeouts (Java 9+)
future.completeOnTimeout("timed out", 500, TimeUnit.MILLISECONDS); // use a default
future.orTimeout(2, TimeUnit.SECONDS); // fail with TimeoutException
Never wait forever on a remote system.
Good habits
- Always shut down executors you create (
pool.shutdown()), or use try-with-resources withExecutorServiceon Java 19+. - Keep the lambdas free of shared mutable state; combine results with
thenCombine/allOfinstead. - Limit concurrency to what the remote system allows — many APIs have rate limits.
- Use async where there’s real waiting; plain sequential code is easier to read when there isn’t.
The full example
// Async.java — CompletableFuture: run work in the background and combine results.
import java.util.List;
import java.util.concurrent.*;
public class Async {
// Simulate slow remote calls (an API, a database)
static String fetchCustomer(String id) { sleep(300); return "Customer " + id; }
static double fetchCreditLimit(String id) { sleep(400); return 5000.0; }
static double fetchOpenBalance(String id) { sleep(200); return 1200.0; }
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(4);
long start = System.currentTimeMillis();
// Start three calls at once
CompletableFuture<String> customer = CompletableFuture.supplyAsync(() -> fetchCustomer("C1"), pool);
CompletableFuture<Double> limit = CompletableFuture.supplyAsync(() -> fetchCreditLimit("C1"), pool);
CompletableFuture<Double> balance = CompletableFuture.supplyAsync(() -> fetchOpenBalance("C1"), pool);
// Combine when two are done
CompletableFuture<Double> available = limit.thenCombine(balance, (l, b) -> l - b);
// Transform and consume
String summary = customer
.thenCombine(available, (c, a) -> c + " can spend " + a)
.thenApply(String::toUpperCase)
.join(); // wait for the result
System.out.println(summary);
long took = System.currentTimeMillis() - start;
System.out.println("Took ~" + took + " ms (one after another it would be ~900 ms)");
// Handle failures without try/catch everywhere
CompletableFuture<String> risky = CompletableFuture
.supplyAsync(() -> { if (true) throw new IllegalStateException("API down"); return "ok"; }, pool)
.exceptionally(ex -> "fallback (" + ex.getCause().getMessage() + ")");
System.out.println(risky.join());
// Wait for many
List<CompletableFuture<String>> calls = List.of("C1", "C2", "C3").stream()
.map(id -> CompletableFuture.supplyAsync(() -> fetchCustomer(id), pool))
.toList();
CompletableFuture.allOf(calls.toArray(new CompletableFuture[0])).join();
calls.forEach(f -> System.out.println(f.join()));
// Timeouts (Java 9+)
String slow = CompletableFuture.supplyAsync(() -> { sleep(2000); return "late"; }, pool)
.completeOnTimeout("timed out", 500, TimeUnit.MILLISECONDS)
.join();
System.out.println(slow);
pool.shutdownNow();
}
}
Try it yourself
Call fetchCustomer for "C1", "C2" and "C3" in parallel and print only the first result to arrive, using anyOf.
Show a solution
CompletableFuture<?>[] calls = Stream.of("C1", "C2", "C3")
.map(id -> CompletableFuture.supplyAsync(() -> fetchCustomer(id), pool))
.toArray(CompletableFuture[]::new);
Object first = CompletableFuture.anyOf(calls).join();
System.out.println("First: " + first);
anyOf completes as soon as any one future completes; its result type is Object.
Key takeaways
supplyAsyncstarts work in the background; pass an executor for I/O.thenApply,thenCompose,thenCombineandallOfbuild pipelines of async steps.exceptionallyandhandlerecover from errors;completeOnTimeout/orTimeoutstop waiting forever.- Async pays off when you’re waiting on slow, independent calls.
🎉 You’ve finished the Java track. Put it to work in integrations — for example with REST APIs in Boomi — or continue with Groovy, Java’s dynamic cousin used in Boomi scripting.