CompletableFuture
Java
asynchronous programming
concurrency
error handling

CompletableFuture not working as expected

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When CompletableFuture behaves unexpectedly, the cause is usually not randomness in the API. It is usually one of three things: the wrong stage method, hidden exceptions, or blocking in the wrong place. CompletableFuture becomes predictable once you are explicit about execution, composition, and error handling.

Start with Stage Semantics

One of the most common mistakes is using thenApply when the mapping function already returns another future.

java
1import java.util.concurrent.CompletableFuture;
2
3public class ComposeDemo {
4    public static void main(String[] args) {
5        CompletableFuture<Integer> future = CompletableFuture
6                .completedFuture("42")
7                .thenApply(Integer::parseInt)
8                .thenCompose(v -> CompletableFuture.supplyAsync(() -> v + 1));
9
10        System.out.println(future.join());
11    }
12}

The rule is:

  • use thenApply for value-to-value transformations
  • use thenCompose for value-to-future transformations

If you use the wrong one, you often end up with nested futures or callbacks that look like they never finish.

Use Explicit Executors for Real Work

The default common pool is fine for simple demos, but it becomes confusing when tasks block or when you need predictable scheduling.

java
1import java.util.concurrent.CompletableFuture;
2import java.util.concurrent.ExecutorService;
3import java.util.concurrent.Executors;
4
5public class ExplicitExecutorDemo {
6    public static void main(String[] args) {
7        ExecutorService ioPool = Executors.newFixedThreadPool(4);
8
9        CompletableFuture<String> future = CompletableFuture
10                .supplyAsync(() -> "data", ioPool)
11                .thenApply(s -> s + "-processed");
12
13        System.out.println(future.join());
14        ioPool.shutdown();
15    }
16}

Using your own executor makes thread usage easier to reason about and debug.

Handle Exceptions Inside the Pipeline

A CompletableFuture that fails does not always fail where you expect. Many bugs come from exceptions surfacing later as CompletionException when join() is called.

java
1import java.util.concurrent.CompletableFuture;
2
3public class ErrorDemo {
4    public static void main(String[] args) {
5        CompletableFuture<Integer> future = CompletableFuture
6                .supplyAsync(() -> 10 / 0)
7                .exceptionally(ex -> {
8                    System.out.println("Recovered from: " + ex.getClass().getSimpleName());
9                    return -1;
10                });
11
12        System.out.println(future.join());
13    }
14}

If you expect failure as part of normal control flow, handle it in the chain rather than letting it explode at the outer boundary.

Avoid Blocking Inside Worker Stages

Another common problem is calling join() too early or too deep in the pipeline. That can cause starvation or deadlock-like behavior when combined with limited executors.

A better aggregation pattern is:

java
1import java.util.List;
2import java.util.concurrent.CompletableFuture;
3
4public class AllOfDemo {
5    public static void main(String[] args) {
6        CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> "A");
7        CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> "B");
8        CompletableFuture<String> c = CompletableFuture.supplyAsync(() -> "C");
9
10        CompletableFuture<List<String>> combined = CompletableFuture
11                .allOf(a, b, c)
12                .thenApply(v -> List.of(a.join(), b.join(), c.join()));
13
14        System.out.println(combined.join());
15    }
16}

Block only at the outer boundary if you can avoid blocking elsewhere.

Timeouts and Cancellation Need a Policy

Without timeouts, a future chain can sit forever.

java
1import java.util.concurrent.CompletableFuture;
2import java.util.concurrent.CompletionException;
3import java.util.concurrent.TimeUnit;
4
5public class TimeoutDemo {
6    public static void main(String[] args) {
7        CompletableFuture<String> future = CompletableFuture
8                .supplyAsync(() -> {
9                    try {
10                        Thread.sleep(2000);
11                    } catch (InterruptedException e) {
12                        throw new RuntimeException(e);
13                    }
14                    return "done";
15                })
16                .orTimeout(500, TimeUnit.MILLISECONDS);
17
18        try {
19            System.out.println(future.join());
20        } catch (CompletionException ex) {
21            System.out.println("Timed out: " + ex.getCause().getClass().getSimpleName());
22        }
23    }
24}

Timeout behavior should be intentional, not an afterthought.

Common Pitfalls

  • Using thenApply where thenCompose is required and accidentally creating nested future logic.
  • Relying on the common pool for blocking work and then wondering why callbacks stall.
  • Ignoring exceptional completion until join() throws far away from the real cause.
  • Blocking inside worker stages instead of composing futures and blocking only at the edge.
  • Forgetting to define timeout or cancellation behavior for operations that may hang.

Summary

  • 'CompletableFuture problems usually come from composition, execution policy, or error handling.'
  • Use thenApply for values and thenCompose for inner futures.
  • Prefer explicit executors when the workload is real or partially blocking.
  • Handle errors and timeouts inside the pipeline instead of treating them as surprises.
  • Keep blocking at the outer boundary and let the async chain stay async internally.

Course illustration
Course illustration

All Rights Reserved.