> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Use CompletableFuture for Async Programming in Java
- URL: https://www.ggorantala.dev/completablefuture-for-async-programming/
- Published: 2026-10-05T05:59:10.000Z
- Updated: 2026-10-05T05:59:10.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: java-tutorials, completablefuture, Java, Learn Java, async-programming, java-concurrency, java-21, multithreading, thread-pool, supplyasync, thencompose, virtual-threads, payment-processing

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:**

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

public record Account(String id, String holder, double balance, String currency) {}
```

```java
// Payment.java
package dev.ggorantala.howto.completablefuture;

public record Payment(String paymentId, String fromAccountId, String toAccountId, double amount) {}
```

```java
// PaymentResult.java
package dev.ggorantala.howto.completablefuture;

public record PaymentResult(String paymentId, boolean success, String message) {}
```

```java
// 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:**

```java
// 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:

```log
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:**

```java
// 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:**

```log
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:**

```java
// 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:**

```log
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:**

```java
// 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:**

```log
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:**

```java
// 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:**

```log
=== 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:**

```java
// 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:**

```log
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
// 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
// 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());
    }
}
```

```log
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
// 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);
        }
    }
}
```

```log
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**

```java
// 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
});
```

```java
// 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**

```java
// WRONG — will hang forever if the upstream service never responds
PaymentResult result = paymentFuture.get();
```

```java
// 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()**

```java
// 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"
}
```

```java
// 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**

```java
// WRONG — if sendAuditEvent throws, the exception is swallowed with no trace
future.thenRun(() -> sendAuditEvent(result));
```

```java
// 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**

```java
// WRONG — thenApply runs on the completing ForkJoinPool thread; a blocking call starves it
future.thenApply(account -> callDownstreamRiskService(account)); // blocks ForkJoinPool thread
```

```java
// 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

```java
// ── 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.