Java
CompletableFuture
Asynchronous Programming
Error Handling
Loop Handling

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:

  1. create one future per input
  2. keep those futures in a list
  3. wait for all of them with CompletableFuture.allOf
  4. extract the results afterward

Example:

java
1import java.util.List;
2import java.util.concurrent.CompletableFuture;
3import java.util.stream.Collectors;
4
5public class Demo {
6    static CompletableFuture<String> fetch(String id) {
7        return CompletableFuture.supplyAsync(() -> "result-" + id);
8    }
9
10    public static void main(String[] args) {
11        List<String> ids = List.of("a", "b", "c");
12
13        List<CompletableFuture<String>> futures = ids.stream()
14                .map(Demo::fetch)
15                .collect(Collectors.toList());
16
17        CompletableFuture<Void> allDone =
18                CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
19
20        List<String> results = allDone.thenApply(v ->
21                futures.stream()
22                        .map(CompletableFuture::join)
23                        .collect(Collectors.toList())
24        ).join();
25
26        System.out.println(results);
27    }
28}

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.

java
1import java.util.List;
2import java.util.concurrent.CompletableFuture;
3import java.util.stream.Collectors;
4
5record FetchResult(String id, String value, Throwable error) { }
6
7public class Demo {
8    static CompletableFuture<String> fetch(String id) {
9        return CompletableFuture.supplyAsync(() -> {
10            if (id.equals("b")) {
11                throw new RuntimeException("boom");
12            }
13            return "result-" + id;
14        });
15    }
16
17    public static void main(String[] args) {
18        List<String> ids = List.of("a", "b", "c");
19
20        List<CompletableFuture<FetchResult>> futures = ids.stream()
21                .map(id -> fetch(id)
22                        .handle((value, error) -> new FetchResult(id, value, error)))
23                .collect(Collectors.toList());
24
25        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
26
27        List<FetchResult> results = futures.stream()
28                .map(CompletableFuture::join)
29                .collect(Collectors.toList());
30
31        results.forEach(System.out::println);
32    }
33}

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.

  • 'handle transforms both success and failure into a new value'
  • 'exceptionally only transforms failure'
  • 'whenComplete observes 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.

java
1import java.util.concurrent.ExecutorService;
2import java.util.concurrent.Executors;
3
4ExecutorService pool = Executors.newFixedThreadPool(8);
5CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "ok", pool);

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.allOf to return a list of results directly.
  • Calling join() on each future before allOf, 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, and whenComplete without 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 handle into a result object.
  • Pick an executor deliberately for nontrivial workloads.
  • The most important design choice is the error policy, not the loop syntax itself.

Course illustration
Course illustration

All Rights Reserved.