What you'll learn

  • Why asynchronous code speeds up I/O-heavy work
  • supplyAsync and your own thread pool
  • thenApply, thenCombine and allOf
  • exceptionally and handle for errors
  • Timeouts with completeOnTimeout and orTimeout

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
MethodUse 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 with ExecutorService on Java 19+.
  • Keep the lambdas free of shared mutable state; combine results with thenCombine/allOf instead.
  • 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

  • supplyAsync starts work in the background; pass an executor for I/O.
  • thenApply, thenCompose, thenCombine and allOf build pipelines of async steps.
  • exceptionally and handle recover from errors; completeOnTimeout/orTimeout stop 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.

Example files