Skip to content

How to Use CompletableFuture for Async Programming in Java

Learn how to use Java's CompletableFuture for async programming — with real payment pipeline examples covering supplyAsync, thenCompose, thenCombine, error handling, and custom thread pools. Covers Java 8, 9, and 21.

java-tutorials completablefuture Java Learn Java async-programming java-concurrency java-21 multithreading thread-pool supplyasync thencompose virtual-threads payment-processing
Gopi Gorantala
Reading Progress

On This Page

In a payment processing backend, a single authorisation request fans out in every direction at once — fetch the sender's account balance, run the fraud score, ping the recipient's bank, write to the audit log — and none of those calls depend on each other. If you're doing them sequentially with blocking threads, you're burning latency and tying up OS resources for no reason. CompletableFuture, introduced in Java 8, gives you a composable, non-blocking way to kick off async work, transform results, combine parallel tasks, and recover from failures — all without callback hell. Let me walk you through it — step by step.

Prerequisites

  • Java 21 LTS — examples also cover Java 8 and Java 9 behaviour
  • IntelliJ IDEA 2024.1 or later (Community or Ultimate)
  • No external dependencies — everything in this article is in the JDK
  • Assumed knowledge: comfortable with lambdas, method references, and basic threading concepts (what a thread pool is, what blocking means). Records (Java 16+) are used for brevity; see the Java 8 section for plain-class equivalents.

What You'll Build

You'll build a payment authorisation pipeline that mirrors what you'd find in a real banking backend. The pipeline fetches the sender's account details asynchronously, runs a fraud check in parallel, and combines both results before producing a final PaymentResult. Along the way you'll add a timeout so the pipeline fails fast when a downstream service stalls, and recovery logic so a fraud-engine outage degrades gracefully rather than crashing the whole operation. By the end you'll have a dedicated, named thread pool so your payment threads are visible in production thread dumps — and never compete with parallelStream() for the common ForkJoinPool.

Step 1 — Create the project structure and domain records

Set up the package and the data types the pipeline will work with. Using Java records keeps the domain classes concise — no getters, no constructors, no noise.

In IntelliJ IDEA: File → New → Project → select Java 21 SDK. Once the project opens, right-click src → New → Package → type dev.ggorantala.howto.completablefuture. Then right-click that package → New → Java Class for each file below.

Write this code:

// Account.java
package dev.ggorantala.howto.completablefuture;

public record Account(String id, String holder, double balance, String currency) {}
// Payment.java
package dev.ggorantala.howto.completablefuture;

public record Payment(String paymentId, String fromAccountId, String toAccountId, double amount) {}
// PaymentResult.java
package dev.ggorantala.howto.completablefuture;

public record PaymentResult(String paymentId, boolean success, String message) {}
// InsufficientFundsException.java
package dev.ggorantala.howto.completablefuture;

public class InsufficientFundsException extends RuntimeException {
    public InsufficientFundsException(String message) {
        super(message);
    }
}

What's happening here: Records are the right tool for these domain types because they're pure data holders — immutable, with no logic. A Payment is a fact: account X sent amount Y to account Z at a specific moment. It shouldn't change after creation.

InsufficientFundsException is unchecked (it extends RuntimeException) deliberately. CompletableFuture wraps checked exceptions inside CompletionException when they propagate through a chain, which means checked exceptions become harder to inspect and match on. Unchecked exceptions travel through async pipelines with far less friction — more on this in the mistakes section.

Step 2 — Run your first async task with supplyAsync

CompletableFuture.supplyAsync() submits a task to a thread pool and immediately returns a CompletableFuture<T> — a handle to a value that will exist in the future. Your calling thread is free to move on without waiting.

In IntelliJ IDEA: Right-click the package → New → Java Class → name it AsyncDemo.

Write this code:

// AsyncDemo.java
package dev.ggorantala.howto.completablefuture;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class AsyncDemo {

    // Simulates a slow I/O call — a database lookup or a REST call to an account service
    private static Account fetchAccountFromDb(String accountId) {
        try {
            Thread.sleep(200); // simulate 200ms network latency
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Interrupted while fetching account", e);
        }
        return switch (accountId) {
            case "ACC-001" -> new Account("ACC-001", "Alice Dupont", 5_000.00, "EUR");
            case "ACC-002" -> new Account("ACC-002", "Bob Martin", 1_200.00, "EUR");
            default -> throw new IllegalArgumentException("Unknown account: " + accountId);
        };
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        System.out.println("Calling thread: " + Thread.currentThread().getName());

        // supplyAsync submits the work to the common ForkJoinPool (we'll fix this in Step 7)
        CompletableFuture<Account> future = CompletableFuture.supplyAsync(
                () -> fetchAccountFromDb("ACC-001")
        );

        System.out.println("After supplyAsync — this line runs immediately, on the calling thread.");

        // .get() blocks until the result is ready — we use it here just to see the value
        Account account = future.get();

        System.out.println("Account fetched: " + account);
    }
}

What's happening here: The key insight is that supplyAsync() returns immediately. The 200ms database call is running on a ForkJoinPool worker thread. Your main thread prints the second line before the account is even fetched.

future.get() is the one blocking call here — we're using it purely to keep the demo alive long enough to print the result. In production code you'd attach callbacks instead of blocking, which is what Step 3 covers.

Notice Thread.currentThread().interrupt() in the catch block. When an InterruptedException is caught, the interrupted status is cleared. Restoring it with interrupt() is the contract: it tells any caller further up the stack that this thread was interrupted, so they can decide whether to propagate or stop.

Run it: Right-click AsyncDemo in the editor gutter → Run 'AsyncDemo.main()'. You'll see:

Calling thread: main
After supplyAsync — this line runs immediately, on the calling thread.
Account fetched: Account[id=ACC-001, holder=Alice Dupont, balance=5000.0, currency=EUR]

The second line prints before the account arrives — that's the whole point.

Step 3 — Transform results with thenApply, thenAccept, and thenRun

Instead of blocking with .get(), you attach callbacks to the CompletableFuture. Three methods cover the three cases: transform the result into a new value, consume the result with a side effect, or fire a side effect with no access to the result at all.

In IntelliJ IDEA: Right-click the package → New → Java Class → CallbackDemo.

Write this code:

// CallbackDemo.java
package dev.ggorantala.howto.completablefuture;

import java.util.concurrent.CompletableFuture;

public class CallbackDemo {

    private static Account fetchAccountFromDb(String accountId) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
        return new Account(accountId, "Alice Dupont", 5_000.00, "EUR");
    }

    private static boolean hasSufficientFunds(Account account, double amount) {
        return account.balance() >= amount;
    }

    public static void main(String[] args) throws InterruptedException {
        double paymentAmount = 1_500.00;

        CompletableFuture<Account> fetchFuture = CompletableFuture.supplyAsync(
                () -> fetchAccountFromDb("ACC-001")
        );

        // thenApply: Account → Boolean (like map on a Stream)
        CompletableFuture<Boolean> validationFuture = fetchFuture
                .thenApply(account -> hasSufficientFunds(account, paymentAmount));

        // thenAccept: consumes the Boolean result, returns CompletableFuture<Void>
        CompletableFuture<Void> logFuture = validationFuture
                .thenAccept(valid -> System.out.println("Funds sufficient: " + valid));

        // thenRun: no input, no output — side effect fires when the chain is done
        logFuture.thenRun(() -> System.out.println("Validation pipeline complete."));

        // Keep main thread alive long enough for the async chain to finish
        Thread.sleep(500);
        System.out.println("Main thread done.");
    }
}

What's happening here: Think of thenApply as map on a stream: it takes the upstream result and produces a new value. thenAccept is forEach: it receives the result but returns CompletableFuture<Void>. thenRun is a pure trigger: it doesn't receive the result at all — it just fires when the previous stage completes.

The chain runs on whatever thread completed the previous stage. If the fetch completes on a ForkJoinPool worker, thenApply also runs on that same worker. That's fine for lightweight transformations. If the transformation is I/O-bound or heavy, use thenApplyAsync(fn, executor) to push it back to the pool rather than tying up the completing thread.

Run it:

Funds sufficient: true
Validation pipeline complete.
Main thread done.

Step 4 — Compose dependent futures with thenCompose

thenApply works when the transformation is a plain function. But what if the next step is itself an async operation that returns a CompletableFuture? Using thenApply there produces a CompletableFuture<CompletableFuture<T>> — a nested future that's nearly impossible to work with. thenCompose flattens it, the same way flatMap flattens nested streams.

In IntelliJ IDEA: Right-click the package → New → Java Class → ComposeDemo.

Write this code:

// ComposeDemo.java
package dev.ggorantala.howto.completablefuture;

import java.util.concurrent.CompletableFuture;

public class ComposeDemo {

    private static Account fetchAccount(String id) {
        simulateDelay(100);
        return new Account(id, "Alice Dupont", 5_000.00, "EUR");
    }

    // This method is itself async — it returns a CompletableFuture
    private static CompletableFuture<PaymentResult> processPayment(Account account, Payment payment) {
        return CompletableFuture.supplyAsync(() -> {
            simulateDelay(150);
            if (account.balance() < payment.amount()) {
                return new PaymentResult(payment.paymentId(), false,
                        "Insufficient funds: balance " + account.balance());
            }
            return new PaymentResult(payment.paymentId(), true, "Payment authorised");
        });
    }

    public static void main(String[] args) throws Exception {
        Payment payment = new Payment("PAY-9901", "ACC-001", "ACC-002", 1_200.00);

        CompletableFuture<PaymentResult> resultFuture = CompletableFuture
                .supplyAsync(() -> fetchAccount(payment.fromAccountId()))
                // thenCompose: the callback returns a CompletableFuture, so the result is flattened
                // thenApply here would give CompletableFuture<CompletableFuture<PaymentResult>>
                .thenCompose(account -> processPayment(account, payment));

        PaymentResult result = resultFuture.get();
        System.out.println("Payment result: " + result);
    }

    private static void simulateDelay(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

What's happening here: The rule of thumb is simple: if your callback returns a plain value, use thenApply. If your callback returns a CompletableFuture, use thenCompose. In a real payment backend, processPayment would call a downstream authorisation service over HTTP — so it would naturally return a CompletableFuture. thenCompose keeps the chain flat so you don't end up unpacking futures manually.

Run it:

Payment result: PaymentResult[paymentId=PAY-9901, success=true, message=Payment authorised]

Step 5 — Combine independent futures with thenCombine and allOf

In a payment pipeline, the account fetch and the fraud check are independent — you don't need to wait for one before starting the other. thenCombine starts two futures in parallel and merges their results when both complete. allOf does the same for N futures.

In IntelliJ IDEA: Right-click the package → New → Java Class → CombineDemo.

Write this code:

// CombineDemo.java
package dev.ggorantala.howto.completablefuture;

import java.util.List;
import java.util.concurrent.CompletableFuture;

public class CombineDemo {

    record FraudCheckResult(String paymentId, boolean flagged, String reason) {}

    private static Account fetchAccount(String id) {
        simulateDelay(200);
        return new Account(id, "Alice Dupont", 5_000.00, "EUR");
    }

    private static FraudCheckResult runFraudCheck(Payment payment) {
        simulateDelay(180); // fraud engine takes ~180ms
        boolean flagged = payment.amount() > 10_000.00;
        return new FraudCheckResult(
                payment.paymentId(),
                flagged,
                flagged ? "Amount exceeds fraud threshold" : "Clean"
        );
    }

    public static void main(String[] args) throws Exception {
        Payment payment = new Payment("PAY-9902", "ACC-001", "ACC-002", 2_500.00);

        long start = System.currentTimeMillis();

        // Both futures start immediately — they run in parallel on the ForkJoinPool
        CompletableFuture<Account> accountFuture = CompletableFuture.supplyAsync(
                () -> fetchAccount(payment.fromAccountId())
        );

        CompletableFuture<FraudCheckResult> fraudFuture = CompletableFuture.supplyAsync(
                () -> runFraudCheck(payment)
        );

        // thenCombine waits for BOTH futures to complete, then merges their results
        CompletableFuture<PaymentResult> resultFuture = accountFuture.thenCombine(
                fraudFuture,
                (account, fraud) -> {
                    if (fraud.flagged()) {
                        return new PaymentResult(payment.paymentId(), false,
                                "Blocked by fraud engine: " + fraud.reason());
                    }
                    if (account.balance() < payment.amount()) {
                        return new PaymentResult(payment.paymentId(), false,
                                "Insufficient funds");
                    }
                    return new PaymentResult(payment.paymentId(), true, "Authorised");
                }
        );

        PaymentResult result = resultFuture.get();
        long elapsed = System.currentTimeMillis() - start;

        System.out.println("Result:  " + result);
        System.out.println("Elapsed: " + elapsed + "ms  (sequential would be ~380ms)");

        // --- allOf demo: validate a batch of payments in parallel ---
        System.out.println("\n--- allOf: parallel batch fraud checks ---");

        List<Payment> batch = List.of(
                new Payment("PAY-001", "ACC-001", "ACC-002", 100.0),
                new Payment("PAY-002", "ACC-001", "ACC-002", 200.0),
                new Payment("PAY-003", "ACC-001", "ACC-002", 300.0)
        );

        List<CompletableFuture<FraudCheckResult>> checks = batch.stream()
                .map(p -> CompletableFuture.supplyAsync(() -> runFraudCheck(p)))
                .toList();

        // allOf completes when ALL the futures complete
        CompletableFuture<Void> allChecks = CompletableFuture.allOf(
                checks.toArray(new CompletableFuture[0])
        );

        // allOf returns Void — collect results from the original list after it completes
        allChecks.thenRun(() -> {
            System.out.println("All fraud checks complete:");
            checks.forEach(f -> System.out.println("  " + f.join()));
        }).get();
    }

    private static void simulateDelay(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

What's happening here: The moment you call supplyAsync on accountFuture and fraudFuture, both tasks are submitted to the thread pool simultaneously. They run in parallel. thenCombine hands you a single future that resolves when both are done and gives you both results in the merge function.

The elapsed time should be around 200ms — the time for the slower task — instead of 380ms sequential. That's the latency win from parallelism.

One important gotcha with allOf: it returns CompletableFuture<Void>. It doesn't carry the individual results. You collect them by calling .join() on each original future after allOf completes — which is exactly what the thenRun block does.

join() and get() both block until the result is ready. The difference: get() throws checked exceptions (ExecutionException, InterruptedException), while join() throws unchecked CompletionException. Inside callbacks, where checked exceptions are awkward, join() is the cleaner choice.

Run it:

Result:  PaymentResult[paymentId=PAY-9902, success=true, message=Authorised]
Elapsed: 202ms  (sequential would be ~380ms)

--- allOf: parallel batch fraud checks ---
All fraud checks complete:
  FraudCheckResult[paymentId=PAY-001, flagged=false, reason=Clean]
  FraudCheckResult[paymentId=PAY-002, flagged=false, reason=Clean]
  FraudCheckResult[paymentId=PAY-003, flagged=false, reason=Clean]

(Elapsed will vary slightly by machine and JVM warmup.)

Step 6 — Handle errors with exceptionally, handle, and whenComplete

Async pipelines need error handling in three different registers: recovery, inspect-and-transform, and pure observation.

In IntelliJ IDEA: Right-click the package → New → Java Class → ErrorHandlingDemo.

Write this code:

// ErrorHandlingDemo.java
package dev.ggorantala.howto.completablefuture;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class ErrorHandlingDemo {

    private static Account fetchAccount(String id) {
        simulateDelay(100);
        if ("UNKNOWN".equals(id)) {
            throw new IllegalArgumentException("Account not found: " + id);
        }
        return new Account(id, "Alice Dupont", 5_000.00, "EUR");
    }

    private static String runFraudCheck(Payment payment) {
        simulateDelay(80);
        // Simulate the fraud engine being temporarily unavailable
        throw new RuntimeException("Fraud engine unavailable — circuit open");
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {

        // --- exceptionally: recover from a failed future ---
        System.out.println("=== exceptionally ===");

        CompletableFuture<Account> accountFuture = CompletableFuture
                .supplyAsync(() -> fetchAccount("UNKNOWN"))
                .exceptionally(ex -> {
                    System.out.println("Recovery triggered: " + ex.getMessage());
                    // Return a sensible default rather than letting the whole chain fail
                    return new Account("GUEST", "Unknown", 0.0, "EUR");
                });

        System.out.println("Account: " + accountFuture.get());

        // --- handle: inspect the result OR the exception in one place ---
        System.out.println("\n=== handle ===");

        Payment payment = new Payment("PAY-0001", "ACC-001", "ACC-002", 500.0);

        CompletableFuture<String> fraudFuture = CompletableFuture
                .supplyAsync(() -> runFraudCheck(payment))
                .handle((result, ex) -> {
                    if (ex != null) {
                        // Degrade gracefully: log and allow (depends on your risk policy)
                        System.out.println("Fraud check failed: " + ex.getMessage()
                                + " — defaulting to ALLOW");
                        return "ALLOWED_BY_DEFAULT";
                    }
                    return result;
                });

        System.out.println("Fraud decision: " + fraudFuture.get());

        // --- whenComplete: observe the outcome without changing it ---
        System.out.println("\n=== whenComplete ===");

        CompletableFuture<Account> observedFuture = CompletableFuture
                .supplyAsync(() -> fetchAccount("ACC-001"))
                .whenComplete((account, ex) -> {
                    if (ex != null) {
                        System.out.println("[AUDIT] Account fetch failed: " + ex.getMessage());
                    } else {
                        System.out.println("[AUDIT] Account fetched: " + account.id());
                    }
                });

        // whenComplete does NOT change the result — the original Account flows through
        System.out.println("Account from observed future: " + observedFuture.get().holder());
    }

    private static void simulateDelay(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

What's happening here:

exceptionally is the rescue clause. It fires only when the future completes exceptionally, and whatever you return becomes the new result of the chain — so the downstream stages see a successful value. Notice the parameter is Throwable, not RuntimeException. That's because CompletableFuture wraps propagated exceptions in CompletionException. If you need the original cause, call ex.getCause().

handle fires whether the future succeeded or failed. When it succeeded, result holds the value and ex is null. When it failed, result is null and ex holds the Throwable. This is the right method for degraded-mode patterns — you inspect the exception, decide whether to recover or rethrow, and transform the result in a single place.

whenComplete is a pure observer. It cannot change the outcome. If the upstream future failed, whenComplete cannot rescue it — the exception propagates past it unchanged. Use whenComplete for side effects that must always fire: writing to an audit log, incrementing a Prometheus counter, sending an alert. The downstream chain sees the exact same result (or exception) it would have seen without whenComplete.

Run it:

=== exceptionally ===
Recovery triggered: java.lang.IllegalArgumentException: Account not found: UNKNOWN
Account: Account[id=GUEST, holder=Unknown, balance=0.0, currency=EUR]

=== handle ===
Fraud check failed: Fraud engine unavailable — circuit open — defaulting to ALLOW
Fraud decision: ALLOWED_BY_DEFAULT

=== whenComplete ===
[AUDIT] Account fetched: ACC-001
Account from observed future: Alice Dupont

Step 7 — Control your thread pool with a custom Executor

By default, supplyAsync() submits to the JVM-wide ForkJoinPool.commonPool(). In a production service, that's a problem: you share this pool with every parallelStream() call and CompletableFuture across your entire JVM. A slow upstream can starve your fraud check — or your health endpoint.

In IntelliJ IDEA: Right-click the package → New → Java Class → PaymentPipeline.

Write this code:

// PaymentPipeline.java
package dev.ggorantala.howto.completablefuture;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class PaymentPipeline {

    private static final AtomicInteger THREAD_COUNTER = new AtomicInteger(0);

    // Dedicated thread pool for payment I/O — never competes with the common ForkJoinPool
    private static final ExecutorService PAYMENT_EXECUTOR =
            Executors.newFixedThreadPool(
                    Runtime.getRuntime().availableProcessors() * 2,
                    r -> {
                        Thread t = new Thread(r,
                                "payment-worker-" + THREAD_COUNTER.incrementAndGet());
                        t.setDaemon(true); // don't block JVM shutdown
                        return t;
                    }
            );

    private static Account fetchAccount(String id) {
        simulateDelay(100);
        System.out.println("fetchAccount on: " + Thread.currentThread().getName());
        return new Account(id, "Alice Dupont", 5_000.00, "EUR");
    }

    private static boolean runFraudCheck(Payment payment) {
        simulateDelay(120);
        System.out.println("fraudCheck on: " + Thread.currentThread().getName());
        return payment.amount() <= 10_000.00;
    }

    public static CompletableFuture<PaymentResult> process(Payment payment) {
        CompletableFuture<Account> accountFuture =
                CompletableFuture.supplyAsync(
                        () -> fetchAccount(payment.fromAccountId()),
                        PAYMENT_EXECUTOR   // ← explicit executor, not common ForkJoinPool
                );

        CompletableFuture<Boolean> fraudFuture =
                CompletableFuture.supplyAsync(
                        () -> runFraudCheck(payment),
                        PAYMENT_EXECUTOR
                );

        return accountFuture
                .thenCombine(fraudFuture, (account, clean) -> {
                    if (!clean) {
                        return new PaymentResult(payment.paymentId(), false,
                                "Flagged by fraud engine");
                    }
                    if (account.balance() < payment.amount()) {
                        return new PaymentResult(payment.paymentId(), false,
                                "Insufficient funds: " + account.balance());
                    }
                    return new PaymentResult(payment.paymentId(), true, "Authorised");
                });
    }

    public static void main(String[] args) throws Exception {
        Payment payment = new Payment("PAY-2024", "ACC-001", "ACC-002", 1_200.00);

        PaymentResult result = process(payment).get(5, TimeUnit.SECONDS);
        System.out.println("Final result: " + result);

        PAYMENT_EXECUTOR.shutdown();
        PAYMENT_EXECUTOR.awaitTermination(5, TimeUnit.SECONDS);
    }

    private static void simulateDelay(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

What's happening here: The thread factory gives every thread a name like payment-worker-1. When you're staring at a production thread dump at 2 AM, payment-worker-3 tells you immediately which pool is saturated. ForkJoinPool-1-worker-3 tells you nothing.

t.setDaemon(true) prevents the thread pool from holding the JVM open after main() returns. In a Spring Boot app you'd instead inject the executor as a @Bean and shut it down in a @PreDestroy method.

availableProcessors() * 2 is a reasonable starting point for I/O-bound work. For CPU-bound work, availableProcessors() is better. Tune with async-profiler or JFR in your actual workload — don't guess.

Notice .get(5, TimeUnit.SECONDS) in main() — always set a timeout on .get(). More on this in the mistakes section.

Run it:

fetchAccount on: payment-worker-1
fraudCheck on: payment-worker-2
Final result: PaymentResult[paymentId=PAY-2024, success=true, message=Authorised]

Thread names will be payment-worker-* — never ForkJoinPool-1-worker-*.


Does this change across Java versions?

Java 8

Java 8 introduced CompletableFuture. The entire API shown in this article is available from Java 8 — with one exception: records require Java 16+. On Java 8, replace them with plain classes:

// Java 8 — no records, no switch expressions
package dev.ggorantala.howto.completablefuture;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutionException;

public class Java8PaymentDemo {

    static final class Account {
        final String id;
        final String holder;
        final double balance;

        Account(String id, String holder, double balance) {
            this.id = id;
            this.holder = holder;
            this.balance = balance;
        }

        @Override
        public String toString() {
            return "Account{id='" + id + "', holder='" + holder + "', balance=" + balance + "}";
        }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(4);

        CompletableFuture<Account> future = CompletableFuture.supplyAsync(
                () -> new Account("ACC-001", "Alice Dupont", 5_000.00),
                executor
        );

        String summary = future
                .thenApply(acc -> "Holder: " + acc.holder + ", Balance: " + acc.balance)
                .exceptionally(ex -> "Fetch failed: " + ex.getMessage())
                .get();

        System.out.println(summary);
        executor.shutdown();
    }
}

The CompletableFuture API itself is identical. The verbosity comes from plain classes instead of records, and switch statements instead of switch expressions.

Java 9

Java 9 added two methods that are mandatory for production use: orTimeout() and completeOnTimeout(). Before Java 9, a future waiting on a silent, hanging downstream service would wait forever.

// Java 9 — orTimeout and completeOnTimeout
package dev.ggorantala.howto.completablefuture;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;

public class Java9TimeoutDemo {

    private static String callSlowAuthService() {
        try {
            Thread.sleep(5_000); // this upstream service is hanging
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "AUTH_OK";
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {

        // orTimeout: fail with TimeoutException if not done within 500ms
        CompletableFuture<String> withTimeout = CompletableFuture
                .supplyAsync(Java9TimeoutDemo::callSlowAuthService)
                .orTimeout(500, TimeUnit.MILLISECONDS);

        try {
            System.out.println(withTimeout.get());
        } catch (ExecutionException e) {
            // e.getCause() is TimeoutException
            System.out.println("Failed fast: " + e.getCause().getClass().getSimpleName());
        }

        // completeOnTimeout: return a fallback value instead of failing
        CompletableFuture<String> withDefault = CompletableFuture
                .supplyAsync(Java9TimeoutDemo::callSlowAuthService)
                .completeOnTimeout("AUTH_DEGRADED", 500, TimeUnit.MILLISECONDS);

        System.out.println("Got: " + withDefault.get());
    }
}
Failed fast: TimeoutException
Got: AUTH_DEGRADED

orTimeout is critical for any payment pipeline that calls Visa, Mastercard, or an internal authorisation service. A silent hang ties up your thread and blocks the customer's checkout session indefinitely without it.

Java 21 — Virtual Threads

Java 21 makes the custom thread pool in Step 7 easier to scale. Virtual threads are JVM-managed lightweight threads — millions fit in a single JVM without exhausting OS resources. Blocking inside a virtual thread (a JDBC call, Thread.sleep(), a REST call) parks the virtual thread rather than blocking the underlying OS thread.

// Java 21 — virtual threads as the executor
package dev.ggorantala.howto.completablefuture;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;

public class Java21VirtualThreadDemo {

    public static void main(String[] args) throws Exception {
        // One virtual thread per task — no pool sizing needed
        // try-with-resources calls shutdown() + awaitTermination() automatically
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {

            var accountFuture = CompletableFuture.supplyAsync(() -> {
                System.out.println("Fetch on: " + Thread.currentThread());
                try { Thread.sleep(100); } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                return new Account("ACC-001", "Alice Dupont", 5_000.00, "EUR");
            }, executor);

            var fraudFuture = CompletableFuture.supplyAsync(() -> {
                System.out.println("Fraud on: " + Thread.currentThread());
                try { Thread.sleep(120); } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                return true; // clean
            }, executor);

            Account account = accountFuture.get();
            boolean clean = fraudFuture.get();

            System.out.println("Account: " + account.holder() + " | Fraud clean: " + clean);
        }
    }
}
Fetch on: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
Fraud on: VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
Account: Alice Dupont | Fraud clean: true

For I/O-bound work — REST calls, JDBC, Kafka consumers — virtual threads are an excellent fit with CompletableFuture. You still want a bounded connection pool for JDBC to avoid overwhelming the database, but you no longer need to carefully size the platform thread pool. The JVM handles the multiplexing.

Common Mistakes to Avoid

Mistake 1 — Calling .get() inside a callback

// WRONG — blocks a thread pool thread while it waits; under load this cascades into starvation
future.thenApply(account -> {
    return anotherFuture.get(); // THIS BLOCKS the completing thread
});
// CORRECT — use thenCompose to chain the dependent async call
future.thenCompose(account -> anotherFuture);

Calling .get() inside a CompletableFuture callback blocks the worker thread that's currently running your callback. Under load, all your worker threads end up blocked waiting on each other — thread starvation, with no exception and no log line to explain it.


Mistake 2 — Using .get() without a timeout

// WRONG — will hang forever if the upstream service never responds
PaymentResult result = paymentFuture.get();
// CORRECT — always set a timeout
try {
    PaymentResult result = paymentFuture.get(2, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    // Fail fast, return a fallback, alert on-call
    logger.error("Payment authorisation timed out for: " + paymentId);
}

Or better, use orTimeout() at the pipeline construction point (Java 9+), so the timeout is baked into the future itself rather than scattered across every call site.


Mistake 3 — Catching ExecutionException without unwrapping getCause()

// WRONG — logs the wrapper, not the real exception class or message
try {
    future.get();
} catch (ExecutionException e) {
    logger.error("Failed: " + e.getMessage()); // prints "dev.ggorantala... Account not found"
}
// CORRECT — unwrap to get the actual exception
try {
    future.get();
} catch (ExecutionException e) {
    Throwable cause = e.getCause();
    logger.error("Payment pipeline failed: " + cause.getMessage(), cause);
    if (cause instanceof InsufficientFundsException ife) {
        // handle specifically
    }
}

ExecutionException is a wrapper. The real exception — your InsufficientFundsException, your IllegalArgumentException, your custom domain exception — is always in getCause(). Logging e.getMessage() gives you a useless string in production dashboards.


Mistake 4 — Letting thenRun or thenAccept failures disappear silently

// WRONG — if sendAuditEvent throws, the exception is swallowed with no trace
future.thenRun(() -> sendAuditEvent(result));
// CORRECT — hold the reference and attach error handling
CompletableFuture<Void> auditFuture = future
        .thenRun(() -> sendAuditEvent(result));

auditFuture.exceptionally(ex -> {
    logger.error("Audit event failed", ex);
    // Alert here — failed audit events are a compliance issue in banking systems
    return null;
});

CompletableFuture is lazy about surfacing errors. If you don't hold a reference to the returned CompletableFuture<Void> and attach error handling, any exception thrown inside thenRun or thenAccept vanishes with no log, no metric, no alert. In a financial system, failed audit writes are a compliance issue — you need to know about them.

Mistake 5 — Using thenApply for heavy or blocking work

// WRONG — thenApply runs on the completing ForkJoinPool thread; a blocking call starves it
future.thenApply(account -> callDownstreamRiskService(account)); // blocks ForkJoinPool thread
// CORRECT — thenApplyAsync pushes the work back to the thread pool
future.thenApplyAsync(account -> callDownstreamRiskService(account), myExecutor);

thenApply runs on the thread that completed the previous stage. For a lightweight transformation (parsing a response, filtering a list), that's fine. For an I/O-bound call — an HTTP request, a database query — use thenApplyAsync with an explicit executor. Otherwise you're blocking a thread pool thread that other tasks need.

When NOT to use CompletableFuture

When you need backpressure. CompletableFuture has no built-in flow control. If a producer submits tasks faster than the thread pool can process them, the task queue grows unboundedly. A burst of incoming payment events can fill your heap before you notice. For pipelines that need backpressure between stages, look at BlockingQueue or a reactive library like Project Reactor.

When you're already in a reactive stack. If your service runs on Spring WebFlux or uses Project Reactor, mixing in CompletableFuture creates an impedance mismatch. You'll convert between Mono/Flux and CompletableFuture at every seam. Reactor has Mono.fromFuture() as an escape hatch, but use it sparingly — stay in one model.

When virtual threads make it unnecessary. On Java 21, a plain blocking call inside a virtual thread is nearly as cheap as a non-blocking async call on a platform thread. If your only reason for adding CompletableFuture complexity is to avoid blocking, evaluate whether Executors.newVirtualThreadPerTaskExecutor() with sequential blocking code would be cleaner and equally fast.

When you only have one async operation. CompletableFuture.supplyAsync(task).get() is strictly worse than calling task directly — you've paid threading and future overhead for zero parallelism. CompletableFuture earns its keep when you're composing multiple async stages or running independent tasks in parallel.

Quick Reference Cheat-Sheet

// ── Create ───────────────────────────────────────────────────────────────────
CompletableFuture.supplyAsync(supplier)               // returns value, uses common FJP
CompletableFuture.supplyAsync(supplier, executor)     // returns value, uses your pool
CompletableFuture.runAsync(runnable, executor)        // no return value
CompletableFuture.completedFuture(value)              // already-done future (great in tests)
CompletableFuture.failedFuture(throwable)             // already-failed future (Java 9+)

// ── Transform (on the completing thread) ─────────────────────────────────────
.thenApply(fn)         // T → U        (like Stream.map)
.thenAccept(consumer)  // T → void     (like Stream.forEach)
.thenRun(runnable)     // void → void  (side effect, no access to result)

// ── Transform (on a thread pool — use for I/O or heavy computation) ──────────
.thenApplyAsync(fn, executor)
.thenAcceptAsync(consumer, executor)
.thenRunAsync(runnable, executor)

// ── Compose (when the callback itself returns a CompletableFuture) ────────────
.thenCompose(fn)       // T → CompletableFuture<U>   (like Stream.flatMap)

// ── Combine ──────────────────────────────────────────────────────────────────
.thenCombine(other, biFunction)       // wait for both, merge results → U
CompletableFuture.allOf(f1, f2, ...)  // wait for all — returns Void; collect via .join()
CompletableFuture.anyOf(f1, f2, ...)  // complete when the FIRST one does → Object

// ── Error handling ───────────────────────────────────────────────────────────
.exceptionally(fn)               // fires on failure only — return a fallback value
.handle(biFunction)              // fires on success OR failure — transform or recover
.whenComplete(biConsumer)        // observe success or failure — CANNOT change the result

// ── Timeouts (Java 9+) ───────────────────────────────────────────────────────
.orTimeout(duration, unit)                       // fail with TimeoutException
.completeOnTimeout(defaultValue, duration, unit) // return default on timeout

// ── Terminal ─────────────────────────────────────────────────────────────────
.get()                       // blocks; throws checked ExecutionException, InterruptedException
.get(timeout, unit)          // blocks with timeout; also throws TimeoutException
.join()                      // blocks; throws unchecked CompletionException
.isDone()                    // non-blocking: true if complete (success, failure, or cancelled)
.isCompletedExceptionally()  // non-blocking: true if failed
.getNow(fallback)            // returns fallback if not done yet — non-blocking

// ── Unwrap exceptions ────────────────────────────────────────────────────────
// After .get()   → catch ExecutionException e → e.getCause() is the real exception
// After .join()  → catch CompletionException e → e.getCause() is the real exception

Was this helpful?

If something in this tutorial didn't work for you — wrong output, a compile error I didn't cover, or a step that wasn't clear — drop a comment below and tell me exactly where it broke. I read every comment and I'll fix the article.

And if you're working through a specific Java problem that you'd love a step-by-step guide for, let me know. I write from what engineers are actually stuck on.

java-tutorialscompletablefutureJavaLearn Javaasync-programmingjava-concurrencyjava-21multithreadingthread-poolsupplyasyncthencomposevirtual-threadspayment-processing

I'm Gopi — 15+ years in Java, building Kafka and Flink platforms for banks, where one lost event is a financial discrepancy. I write javahandbook.com because the guides I needed didn't exist. Everything here is tested against a real cluster first.

Comments