CompletableFuture

July 16, 20265 min readUpdated 8/20/2026

CompletableFuture runs work on another thread and lets you describe what should happen when it finishes — without blocking to wait for it. It is how you stop making a caller wait for three slow things in sequence when they could happen at once.

Starting work

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return "result";                         // runs on another thread
});

CompletableFuture<Void> sideEffect = CompletableFuture.runAsync(() -> {
    System.out.println("logging something"); // no return value
});

System.out.println(future.join());           // blocks until done -> result

supplyAsync when you want a value back, runAsync when you do not. Both return immediately; the work happens elsewhere.

join() waits for the answer. Note that calling it straight away, as above, gains you nothing — you have started a thread and then blocked on it. The value comes from doing something else in between, or from chaining.

Chaining what happens next

CompletableFuture<Integer> pipeline = CompletableFuture
        .supplyAsync(() -> "42")
        .thenApply(Integer::parseInt)                // transform the result
        .thenApply(n -> n * 2);                      // and again

System.out.println(pipeline.join());                 // 84

CompletableFuture<Void> consumed = CompletableFuture
        .supplyAsync(() -> "done")
        .thenAccept(System.out::println)             // consume it, return nothing
        .thenRun(() -> System.out.println("finished"));   // ignore the value entirely

None of those thenX calls blocks. They register what to do when the previous stage completes, and return a new future representing the stage after that.

thenApply versus thenCompose

The distinction people get wrong. Use thenApply when your function returns a plain value, and thenCompose when it returns another future:

class Service {
    CompletableFuture<String> findUserId(String email) {
        return CompletableFuture.supplyAsync(() -> "user-1");
    }

    CompletableFuture<String> loadProfile(String userId) {
        return CompletableFuture.supplyAsync(() -> "profile of " + userId);
    }

    void run() {
        // Wrong shape: a future of a future
        CompletableFuture<CompletableFuture<String>> nested =
                findUserId("a@b.com").thenApply(this::loadProfile);

        // Right: flattened
        CompletableFuture<String> flat =
                findUserId("a@b.com").thenCompose(this::loadProfile);

        System.out.println(flat.join());        // profile of user-1
    }
}

If you know streams, this is exactly map versus flatMap, and for the same reason.

Running several at once

This is where the real gain is. Three calls that each take a second take one second in total, not three:

class Dashboard {
    CompletableFuture<String> fetchProfile() { return CompletableFuture.supplyAsync(() -> "profile"); }
    CompletableFuture<String> fetchOrders() { return CompletableFuture.supplyAsync(() -> "orders"); }
    CompletableFuture<String> fetchOffers() { return CompletableFuture.supplyAsync(() -> "offers"); }

    void run() {
        CompletableFuture<String> profile = fetchProfile();   // all three start now
        CompletableFuture<String> orders = fetchOrders();
        CompletableFuture<String> offers = fetchOffers();

        CompletableFuture.allOf(profile, orders, offers).join();   // wait for all

        System.out.println(profile.join() + " " + orders.join() + " " + offers.join());
    }
}

Start every future before joining any of them. Calling fetchProfile().join() on one line and fetchOrders().join() on the next runs them in sequence and throws away the entire point.

allOf returns CompletableFuture<Void>, so you collect the individual results afterwards — by then they are all complete, so join() returns instantly. There is also anyOf, which completes as soon as the first one does.

To combine exactly two, thenCombine is tidier:

CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 20);
CompletableFuture<Integer> b = CompletableFuture.supplyAsync(() -> 22);

System.out.println(a.thenCombine(b, Integer::sum).join());     // 42

Exceptions

An exception inside a stage skips every subsequent stage and surfaces at the end, so handle it where you can do something useful:

CompletableFuture<String> recovered = CompletableFuture
        .<String>supplyAsync(() -> { throw new IllegalStateException("boom"); })
        .exceptionally(e -> "fallback");                // only runs on failure

System.out.println(recovered.join());                   // fallback

CompletableFuture<String> both = CompletableFuture
        .supplyAsync(() -> "ok")
        .handle((value, error) -> error != null ? "failed" : value.toUpperCase());

System.out.println(both.join());                        // OK

exceptionally handles only the failure case; handle receives both the value and the error and runs either way. whenComplete is the third option — it observes both without changing the result, which is what you want for logging.

Note that join() wraps whatever went wrong in a CompletionException, so the exception you catch is not the one you threw. Use getCause() to reach it.

Pass your own executor

Every example above uses the default, which is the shared ForkJoinPool.commonPool(). That pool is sized for CPU-bound work and is shared with parallel streams across your whole application. Blocking it on network calls will starve everything else:

class Service {
    void run() {
        ExecutorService pool = Executors.newFixedThreadPool(10);
        try {
            CompletableFuture<String> f =
                    CompletableFuture.supplyAsync(() -> "io result", pool);   // your pool
            System.out.println(f.join());
        } finally {
            pool.shutdown();                     // always shut it down
        }
    }
}

The rule: for anything that waits on I/O, pass your own executor. Leave the common pool for short CPU-bound work.

On Java 21 and later there is a better option for I/O — a virtual thread executor, which creates a cheap thread per task rather than sharing a small pool:

class Service {
    void run() {
        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> "io result", pool);
            System.out.println(f.join());
        }
    }
}

Virtual threads are cheap enough that blocking one costs almost nothing, which removes most of the reason to build elaborate async pipelines in the first place.

Timeouts, and the one you must not forget

A future that never completes will keep a caller waiting forever. Since Java 9 there is a built-in answer:

CompletableFuture<String> slow = CompletableFuture.supplyAsync(() -> "eventually");

System.out.println(slow.completeOnTimeout("default", 2, TimeUnit.SECONDS).join());

CompletableFuture<String> strict = CompletableFuture
        .supplyAsync(() -> "eventually")
        .orTimeout(2, TimeUnit.SECONDS)                 // fails with TimeoutException instead
        .exceptionally(e -> "gave up");

System.out.println(strict.join());

completeOnTimeout substitutes a fallback value; orTimeout fails the future so an exceptionally stage can decide. Either is better than the third option, which is waiting indefinitely.

The three things that go wrong

  • Blocking immediately. supplyAsync(...).join() on one line is a thread handoff and a wait, which is slower than just calling the method. The gain comes from starting several, or from returning the future to a caller who will use it later.
  • Sequential joins. Joining each future before starting the next serialises work you meant to parallelise. Start them all, then join.
  • Swallowed failures. A future whose result nobody joins and which has no exceptionally or whenComplete fails silently — no stack trace anywhere. Always terminate a chain with something that observes the outcome.

Next

That closes the Java 8 shift. The next section walks each LTS release since, starting with the Java 11 String methods.