Java
Asynchronous Programming
Request Synchronization
Concurrency
Thread Management

Syncing multiple asynchronous requests in Java

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

When several asynchronous requests must finish before the next step can run, the core problem is coordination. In modern Java, the cleanest tool for this is usually CompletableFuture, because it lets you start requests concurrently, wait for all of them, and combine their results without manually managing thread joins. The goal is to synchronize completion, not to turn asynchronous code back into blocking code too early.

Start Independent Requests Concurrently

Suppose you need data from three services before building a response. Start them independently with supplyAsync.

java
1import java.util.concurrent.CompletableFuture;
2
3public class Demo {
4    static CompletableFuture<String> fetchProfile() {
5        return CompletableFuture.supplyAsync(() -> "profile");
6    }
7
8    static CompletableFuture<String> fetchOrders() {
9        return CompletableFuture.supplyAsync(() -> "orders");
10    }
11
12    static CompletableFuture<String> fetchRecommendations() {
13        return CompletableFuture.supplyAsync(() -> "recommendations");
14    }
15}

Each task begins running without waiting for the others.

Wait for All Requests with allOf

Use CompletableFuture.allOf when all requests must complete before you proceed.

java
1import java.util.concurrent.CompletableFuture;
2
3public class Demo {
4    public static void main(String[] args) {
5        CompletableFuture<String> profile = fetchProfile();
6        CompletableFuture<String> orders = fetchOrders();
7        CompletableFuture<String> recommendations = fetchRecommendations();
8
9        CompletableFuture<Void> allDone = CompletableFuture.allOf(
10            profile,
11            orders,
12            recommendations
13        );
14
15        CompletableFuture<String> combined = allDone.thenApply(ignored ->
16            profile.join() + " | " + orders.join() + " | " + recommendations.join()
17        );
18
19        System.out.println(combined.join());
20    }
21
22    static CompletableFuture<String> fetchProfile() {
23        return CompletableFuture.supplyAsync(() -> "profile");
24    }
25
26    static CompletableFuture<String> fetchOrders() {
27        return CompletableFuture.supplyAsync(() -> "orders");
28    }
29
30    static CompletableFuture<String> fetchRecommendations() {
31        return CompletableFuture.supplyAsync(() -> "recommendations");
32    }
33}

allOf completes when every supplied future finishes. After that, calling join() on the individual futures is safe because they are already done.

Combine Smaller Groups with thenCombine

If you only need to merge two results at a time, thenCombine can be clearer than a separate allOf call.

java
1CompletableFuture<String> summary = fetchProfile()
2    .thenCombine(fetchOrders(), (profile, orders) -> profile + " + " + orders);
3
4System.out.println(summary.join());

This reads naturally when one result depends on combining exactly two asynchronous values.

Handle Failures Explicitly

Synchronization is incomplete without error handling. If one future fails, allOf completes exceptionally.

java
CompletableFuture<String> safeProfile = fetchProfile()
    .exceptionally(error -> "profile-unavailable");

You can recover per request or let the combined result fail and handle it once at the end.

java
1try {
2    System.out.println(combined.join());
3} catch (Exception ex) {
4    System.err.println("At least one async request failed: " + ex.getMessage());
5}

Which strategy is right depends on whether partial results are acceptable.

Avoid Blocking Too Early

A common mistake is to start an asynchronous request and immediately call get() or join() on it. That defeats the point of running requests concurrently.

Bad pattern:

java
String profile = fetchProfile().join();
String orders = fetchOrders().join();

That sequence blocks after each call instead of allowing the requests to overlap.

Start them first, then synchronize later.

Use the Right Executor

Real asynchronous requests should usually run on an executor sized for the work they do. If every request blocks on network I/O, a tiny executor can become the bottleneck even when the coordination logic is correct.

Separating compute-heavy tasks from I/O-heavy tasks also makes failures and latency easier to reason about.

Common Pitfalls

  • Calling join() immediately after creating each future and losing concurrency.
  • Using raw threads when CompletableFuture composition would be simpler.
  • Ignoring exceptions until a combined future fails unexpectedly.
  • Using allOf and then forgetting to read the individual results.
  • Running blocking I/O on an executor that was meant for lightweight tasks.

Summary

  • Start independent asynchronous requests before waiting on any of them.
  • Use CompletableFuture.allOf when all results are required.
  • Use thenCombine for simple pairwise combinations.
  • Decide explicitly how failures should affect the combined outcome.
  • Delay blocking until the point where synchronization is actually needed.

Related reading
Course
Intermediate
27 lessons
14 hours
OOD Fundamentals

Master object-oriented design from first principles, SOLID, design patterns, and classic interview problems with hands-on coding.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.