CompletableFuture in loop How to collect all responses and handle errors
Master System Design with Codemia
Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.
Introduction
When you create many CompletableFuture instances in a loop, the real challenge is not starting them. It is collecting their results in a way that matches your error policy. Sometimes one failure should fail the whole operation. Sometimes you want partial success and a separate error report. The structure of the code should make that policy explicit.
Start Futures in the Loop, Collect Them Later
A common pattern is:
- create one future per input
- keep those futures in a list
- wait for all of them with
CompletableFuture.allOf - extract the results afterward
Example:
This is the standard pattern when success from every future is required.
Understand What allOf Does Not Do
CompletableFuture.allOf(...) waits for all futures to complete, but it does not automatically collect their results into a list. You still need to read each future afterward.
That is why the join() calls happen after allDone completes.
Fail-Fast Versus Collect-All Errors
If any future completes exceptionally, join() on that future throws. That may be exactly what you want, but not always.
If the goal is "stop the whole batch on failure," the previous pattern is fine.
If the goal is "collect all responses and all errors," wrap each future result into a success-or-failure object.
Collect Successes and Failures Explicitly
A practical pattern is to convert each future so it always completes normally with a wrapper object.
Now you can inspect successes and failures together without the whole batch collapsing on the first exception.
handle, exceptionally, and whenComplete Are Different
These methods are often mixed up.
- '
handletransforms both success and failure into a new value' - '
exceptionallyonly transforms failure' - '
whenCompleteobserves the outcome but does not change it'
For collect-all workflows, handle is usually the cleanest because it lets every future become a normal wrapper result.
Choose the Executor Deliberately
CompletableFuture.supplyAsync() uses the common fork-join pool by default. That may be fine for lightweight async work, but explicit executors are often better for I/O-heavy or service-call workloads.
Executor choice affects throughput, backpressure, and resource control, especially inside loops that create many futures.
Keep the Loop Policy Obvious
The most maintainable code makes the batch rule clear:
- all must succeed
- collect partial success
- ignore some errors
- retry selected failures
Do not hide that policy inside nested lambda chains that nobody can reason about later.
Common Pitfalls
- Expecting
CompletableFuture.allOfto return a list of results directly. - Calling
join()on each future beforeallOf, which defeats the point of coordinated waiting. - Letting one exceptional future kill the whole batch when partial success was actually acceptable.
- Using the common pool blindly for large loops without thinking about executor behavior.
- Mixing
handle,exceptionally, andwhenCompletewithout understanding their different semantics.
Summary
- Create futures in the loop, then coordinate them with
CompletableFuture.allOf. - Use
join()after all completion when every future must succeed. - If you need both successes and errors, wrap each future with
handleinto a result object. - Pick an executor deliberately for nontrivial workloads.
- The most important design choice is the error policy, not the loop syntax itself.

