CompletableFuture
ForkJoinPool
asynchronous programming
concurrency
Java threads

Use CompletableFuture on ForkJoinpool and avoid thread waiting

Master System Design with Codemia

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

Introduction

Using CompletableFuture with a ForkJoinPool works well only when the tasks stay genuinely asynchronous and non-blocking. The most common mistake is to submit work to the pool and then call get() or join() inside another task running on the same limited pool, which turns concurrency into thread waiting.

Why Waiting Inside the Pool Is a Problem

ForkJoinPool is designed for short compute-oriented tasks and work stealing. If worker threads spend their time blocked on other futures, the pool can lose throughput and in bad designs can even stall progress.

This is the pattern to avoid:

java
1CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> slowCallA(), pool);
2CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> slowCallB(), pool);
3
4CompletableFuture<String> combined = CompletableFuture.supplyAsync(() -> {
5    return a.join() + b.join();
6}, pool);

The combining task itself occupies a pool thread while waiting for other tasks. That is exactly the kind of avoidable blocking CompletableFuture is meant to remove.

Compose Futures Instead of Waiting

The normal fix is to chain futures rather than blocking on them.

java
1import java.util.concurrent.CompletableFuture;
2import java.util.concurrent.ForkJoinPool;
3
4ForkJoinPool pool = new ForkJoinPool(4);
5
6CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> slowCallA(), pool);
7CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> slowCallB(), pool);
8
9CompletableFuture<String> combined = a.thenCombine(b, (left, right) -> left + right);

Here no worker thread waits just to combine results. The combination runs when both inputs complete.

Use thenCompose for Dependent Async Steps

If the second step itself returns a future, use thenCompose instead of calling join() inside a callback.

java
CompletableFuture<String> pipeline =
    CompletableFuture.supplyAsync(() -> fetchUserId(), pool)
        .thenCompose(userId -> CompletableFuture.supplyAsync(() -> loadProfile(userId), pool));

This keeps the workflow asynchronous from end to end.

Gather Many Futures with allOf

For several independent tasks, allOf is often cleaner than a web of nested callbacks.

java
1CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> slowCallA(), pool);
2CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> slowCallB(), pool);
3CompletableFuture<String> c = CompletableFuture.supplyAsync(() -> slowCallC(), pool);
4
5CompletableFuture<Void> all = CompletableFuture.allOf(a, b, c);
6CompletableFuture<String> result = all.thenApply(v -> a.join() + b.join() + c.join());

This is acceptable because the join() calls happen only after allOf has completed, so they are not blocking on unfinished work.

Use the Right Executor for the Workload

Another important design point is that ForkJoinPool is best for CPU-oriented tasks. If the work is blocking I/O such as database calls or HTTP requests, a dedicated executor is often a better choice.

java
ExecutorService ioPool = Executors.newFixedThreadPool(20);
CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> httpCallA(), ioPool);

Using the wrong executor is often the deeper bug behind "threads are waiting" complaints.

Keep Blocking at the Boundary

Sometimes you do need to wait for a result, but do it at the application boundary, not inside the internal async graph.

Examples of acceptable boundaries include:

  • returning a final value from main
  • adapting to an older synchronous API
  • waiting in a top-level test method

Inside the future graph itself, prefer composition over blocking.

Common Pitfalls

  • Calling join() or get() inside tasks running on the same ForkJoinPool.
  • Using supplyAsync everywhere but still writing the workflow as if it were synchronous.
  • Running blocking I/O on a compute-oriented fork-join pool.
  • Forgetting that allOf(...).thenApply(...) is safer than waiting in an intermediate task.
  • Blocking deep inside the async pipeline instead of only at the outer boundary when necessary.

Summary

  • 'CompletableFuture avoids waiting only if you compose futures instead of blocking on them.'
  • Use thenCombine, thenCompose, and allOf to build non-blocking workflows.
  • Avoid join() and get() inside fork-join worker tasks.
  • Use an executor suited to the actual workload, especially for blocking I/O.
  • If blocking is unavoidable, keep it at the boundary of the system rather than inside the async graph.

Course illustration
Course illustration

All Rights Reserved.