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:
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.
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.
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.
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.
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()orget()inside tasks running on the sameForkJoinPool. - Using
supplyAsynceverywhere 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
- '
CompletableFutureavoids waiting only if you compose futures instead of blocking on them.' - Use
thenCombine,thenCompose, andallOfto build non-blocking workflows. - Avoid
join()andget()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.

