Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 17 min read

Building an Async Java REST Client with CompletableFuture, Concurrent Requests, and Virtual Threads

Synchronous HTTP code is simple. But sometimes your application needs to call several APIs at once, reduce waiting time, or avoid tying up threads while requests are in flight. That is where asynchronous programming be

Synchronous HTTP code is simple.

But sometimes your application needs to call several APIs at once, reduce waiting time, or avoid tying up threads while requests are in flight.

That is where asynchronous programming becomes useful.

In this tutorial, we'll build on Java's built-in HttpClient and explore:

  • sendAsync()
  • CompletableFuture
  • Running multiple HTTP requests concurrently
  • Combining several API responses
  • Handling async errors
  • Applying timeouts
  • Using Jackson with asynchronous responses
  • Comparing CompletableFuture with virtual threads
  • Understanding when async code is actually worth using

By the end, you'll have several practical patterns for building concurrent REST clients in modern Java.

1. Synchronous vs Asynchronous HTTP

A normal synchronous request looks like this:

HttpResponse<String> response = client.send(
        request,
        HttpResponse.BodyHandlers.ofString()
);

The current thread waits until the response arrives.

Conceptually:

Send request
    ↓
Wait
    ↓
Wait
    ↓
Receive response
    ↓
Continue

This is easy to understand.

But if you need to call three independent APIs:

API A β†’ 2 seconds
API B β†’ 3 seconds
API C β†’ 1 second

and you call them one after another, the total time can be roughly:

2 + 3 + 1 = 6 seconds

That is where concurrency can help.

2. HttpClient.sendAsync()

Java's HttpClient has an asynchronous method:

sendAsync()

Instead of returning:

HttpResponse<String>

it returns:

CompletableFuture<HttpResponse<String>>

Example:

CompletableFuture<HttpResponse<String>> future =
        client.sendAsync(
                request,
                HttpResponse.BodyHandlers.ofString()
        );

The request starts, but the current thread does not block waiting for the result.

3. Your First Async Request

Let's create a simple example.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CompletableFuture;

public class AsyncExample {

    public static void main(String[] args) {

        HttpClient client =
                HttpClient.newHttpClient();

        HttpRequest request =
                HttpRequest.newBuilder()
                        .uri(URI.create(
                                "https://jsonplaceholder.typicode.com/posts/1"
                        ))
                        .GET()
                        .build();

        CompletableFuture<HttpResponse<String>> future =
                client.sendAsync(
                        request,
                        HttpResponse.BodyHandlers.ofString()
                );

        future.thenAccept(response -> {
            System.out.println(
                    response.statusCode()
            );

            System.out.println(
                    response.body()
            );
        });

        future.join();
    }
}

The important line is:

future.thenAccept(...)

This says:

When the response arrives, run this code.

4. What Does join() Do?

In the previous example, we used:

future.join();

Why?

Because this is a small console application.

Without it, the main method could finish before the asynchronous response is processed.

join() waits for the CompletableFuture to complete.

So although the HTTP operation is asynchronous, our demo waits at the end so the application stays alive.

In larger applications, you often won't call join() immediately.

5. thenApply() vs thenAccept()

These two methods are commonly used with CompletableFuture.

thenAccept()

Use it when you want to consume a result but not return another value.

future.thenAccept(response -> {
    System.out.println(
            response.body()
    );
});

thenApply()

Use it when you want to transform the result.

CompletableFuture<String> bodyFuture =
        future.thenApply(
                HttpResponse::body
        );

Now:

bodyFuture

is:

CompletableFuture<String>

rather than:

CompletableFuture<HttpResponse<String>>

6. Transforming an HTTP Response

A common pattern is:

CompletableFuture<String> result =
        client.sendAsync(
                request,
                HttpResponse.BodyHandlers.ofString()
        )
        .thenApply(
                HttpResponse::body
        );

Then:

String body = result.join();

This is much cleaner if you only care about the body.

But in production code, you should usually check the status code first.

7. Validate the Status Code

Let's create a helper:

private static HttpResponse<String> validate(
        HttpResponse<String> response
) {

    int status =
            response.statusCode();

    if (status >= 200 &&
        status < 300) {

        return response;
    }

    throw new RuntimeException(
            "HTTP " +
            status +
            ": " +
            response.body()
    );
}

Now:

CompletableFuture<String> bodyFuture =
        client.sendAsync(
                request,
                HttpResponse.BodyHandlers.ofString()
        )
        .thenApply(
                AsyncExample::validate
        )
        .thenApply(
                HttpResponse::body
        );

The flow becomes:

sendAsync()
    ↓
validate()
    ↓
extract body

8. Add Jackson

Now let's combine async HTTP with Jackson.

Suppose an API returns:

{
  "id": 1,
  "title": "Hello",
  "body": "Example",
  "userId": 1
}

Create a record:

public record Post(
        int id,
        String title,
        String body,
        int userId
) {
}

Then use Jackson:

ObjectMapper mapper =
        new ObjectMapper();

We can parse the response asynchronously.

9. Async JSON Deserialization

Example:

CompletableFuture<Post> postFuture =
        client.sendAsync(
                request,
                HttpResponse.BodyHandlers.ofString()
        )
        .thenApply(
                AsyncExample::validate
        )
        .thenApply(
                HttpResponse::body
        )
        .thenApply(body -> {

            try {

                return mapper.readValue(
                        body,
                        Post.class
                );

            } catch (Exception e) {

                throw new RuntimeException(e);
            }
        });

Now:

postFuture

is:

CompletableFuture<Post>

Usage:

Post post =
        postFuture.join();

System.out.println(
        post.title()
);

10. Create a Reusable Async GET Method

Let's make the pattern reusable.

public <T> CompletableFuture<T> getAsync(
        String url,
        Class<T> responseType
) {

    HttpRequest request =
            HttpRequest.newBuilder()
                    .uri(URI.create(url))
                    .header(
                            "Accept",
                            "application/json"
                    )
                    .GET()
                    .build();

    return client.sendAsync(
            request,
            HttpResponse.BodyHandlers.ofString()
    )
    .thenApply(this::validate)
    .thenApply(response -> {

        try {

            return mapper.readValue(
                    response.body(),
                    responseType
            );

        } catch (IOException e) {

            throw new CompletionException(e);
        }
    });
}

Now calling an API becomes:

CompletableFuture<User> future =
        restClient.getAsync(
                "https://example.com/users/1",
                User.class
        );

11. Why CompletionException?

Inside a CompletableFuture pipeline, checked exceptions are awkward.

Jackson can throw:

IOException

but a lambda passed to thenApply() cannot simply throw it.

A common pattern is:

throw new CompletionException(e);

This wraps the checked exception inside an unchecked exception that works naturally with CompletableFuture.

12. Run Multiple Requests Concurrently

This is where async becomes especially useful.

Suppose we want:

/users/1
/posts/1
/todos/1

We can start all three requests immediately:

CompletableFuture<User> userFuture =
        client.getAsync(
                userUrl,
                User.class
        );

CompletableFuture<Post> postFuture =
        client.getAsync(
                postUrl,
                Post.class
        );

CompletableFuture<Todo> todoFuture =
        client.getAsync(
                todoUrl,
                Todo.class
        );

At this point, all three requests can be in flight at the same time.

13. Sequential vs Concurrent Timing

Sequential:

Request A
    ↓
wait
    ↓
Request B
    ↓
wait
    ↓
Request C

Concurrent:

Request A ──────────────┐
Request B ──────────┐   β”‚
Request C ───────┐  β”‚   β”‚
                 ↓  ↓   ↓
               results arrive

If the requests are independent, concurrent execution can reduce total waiting time significantly.

14. Combine Two Futures with thenCombine()

Suppose we want to combine:

CompletableFuture<User>

and:

CompletableFuture<List<Post>>

We can use:

thenCombine()

Example:

CompletableFuture<UserProfile> profileFuture =
        userFuture.thenCombine(
                postsFuture,
                (user, posts) ->
                        new UserProfile(
                                user,
                                posts
                        )
        );

Now:

profileFuture

contains both results.

15. Example UserProfile

public record UserProfile(
        User user,
        List<Post> posts
) {
}

Then:

UserProfile profile =
        profileFuture.join();

This is useful when several endpoints contribute to one final result.

16. Combine Many Futures with allOf()

thenCombine() works well for two futures.

For many futures, use:

CompletableFuture.allOf(...)

Example:

CompletableFuture<User> userFuture =
        getUser();

CompletableFuture<List<Post>> postsFuture =
        getPosts();

CompletableFuture<List<Todo>> todosFuture =
        getTodos();

Then:

CompletableFuture<Void> all =
        CompletableFuture.allOf(
                userFuture,
                postsFuture,
                todosFuture
        );

Wait for all:

all.join();

Then retrieve:

User user =
        userFuture.join();

List<Post> posts =
        postsFuture.join();

List<Todo> todos =
        todosFuture.join();

17. Build the Final Object After allOf()

A cleaner approach:

CompletableFuture<Dashboard> dashboardFuture =
        CompletableFuture.allOf(
                userFuture,
                postsFuture,
                todosFuture
        )
        .thenApply(ignored ->
                new Dashboard(
                        userFuture.join(),
                        postsFuture.join(),
                        todosFuture.join()
                )
        );

Now:

Dashboard dashboard =
        dashboardFuture.join();

18. A Helper for Lists of Futures

Suppose we have:

List<CompletableFuture<User>>

and want:

CompletableFuture<List<User>>

We can create:

public static <T>
CompletableFuture<List<T>> sequence(
        List<CompletableFuture<T>> futures
) {

    CompletableFuture<Void> all =
            CompletableFuture.allOf(
                    futures.toArray(
                            CompletableFuture[]::new
                    )
            );

    return all.thenApply(
            ignored ->
                    futures.stream()
                            .map(
                                    CompletableFuture::join
                            )
                            .toList()
    );
}

Usage:

List<CompletableFuture<User>> futures =
        ids.stream()
                .map(
                        id ->
                                client.getAsync(
                                        "/users/" + id,
                                        User.class
                                )
                )
                .toList();

CompletableFuture<List<User>> usersFuture =
        sequence(futures);

This is a very useful pattern.

19. But Don't Launch Unlimited Requests

There is an important danger.

Imagine:

10,000 user IDs

and you do:

ids.stream()
        .map(id -> getAsync(...))
        .toList();

You might launch thousands of HTTP requests at once.

That can:

  • Overload the remote API
  • Hit rate limits
  • Exhaust resources
  • Create huge memory pressure

Concurrency still needs limits.

20. Limit Concurrency

One simple approach is batching.

Instead of:

10,000 concurrent requests

do:

50 at a time

For example:

int batchSize = 50;

Then process IDs in batches.

Conceptually:

Batch 1
    β†’ 50 requests
    β†’ wait

Batch 2
    β†’ 50 requests
    β†’ wait

Batch 3
    β†’ 50 requests

This is often safer than unlimited concurrency.

21. Async Error Handling with exceptionally()

Suppose a request fails.

You can handle it using:

exceptionally()

Example:

CompletableFuture<User> future =
        client.getAsync(
                url,
                User.class
        )
        .exceptionally(error -> {

            System.err.println(
                    error.getMessage()
            );

            return null;
        });

This converts the failed future into a successful future containing:

null

That can be useful, but be careful.

Returning null can hide failures.

22. Use handle() When You Need Both Cases

handle() receives both:

result
error

Example:

future.handle(
        (user, error) -> {

            if (error != null) {

                System.err.println(
                        "Request failed: " +
                        error.getMessage()
                );

                return fallbackUser();
            }

            return user;
        }
);

This is useful for fallback logic.

23. Use whenComplete() for Logging

Sometimes you don't want to change the result.

You just want to observe it.

Use:

whenComplete()

Example:

future.whenComplete(
        (result, error) -> {

            if (error != null) {

                System.err.println(
                        "Failed: " +
                        error.getMessage()
                );

            } else {

                System.out.println(
                        "Request completed"
                );
            }
        }
);

The original future result continues unchanged.

24. exceptionally() vs handle() vs whenComplete()

A useful rule:

exceptionally()
    β†’ recover only from failure

handle()
    β†’ transform success or failure

whenComplete()
    β†’ observe success or failure

25. Add Async Timeouts

CompletableFuture provides timeout helpers.

Fail after a timeout

future.orTimeout(
        5,
        TimeUnit.SECONDS
);

If the future does not complete in time, it fails.

Return a fallback value

future.completeOnTimeout(
        fallbackUser,
        5,
        TimeUnit.SECONDS
);

This can be useful in applications where partial results are acceptable.

26. HTTP Timeout vs Future Timeout

These are different.

HttpRequest.timeout(...)

Controls the HTTP request.

HttpRequest.newBuilder()
        .timeout(
                Duration.ofSeconds(10)
        )

CompletableFuture.orTimeout(...)

Controls the future pipeline.

future.orTimeout(
        12,
        TimeUnit.SECONDS
);

Using both can provide stronger protection.

27. Async POST Requests

POST can also be asynchronous.

Example:

public <T, R>
CompletableFuture<R> postAsync(
        String url,
        T requestBody,
        Class<R> responseType
) {

    try {

        String json =
                mapper.writeValueAsString(
                        requestBody
                );

        HttpRequest request =
                HttpRequest.newBuilder()
                        .uri(URI.create(url))
                        .header(
                                "Content-Type",
                                "application/json"
                        )
                        .POST(
                                HttpRequest.BodyPublishers
                                        .ofString(json)
                        )
                        .build();

        return client.sendAsync(
                request,
                HttpResponse.BodyHandlers.ofString()
        )
        .thenApply(this::validate)
        .thenApply(response -> {

            try {

                return mapper.readValue(
                        response.body(),
                        responseType
                );

            } catch (IOException e) {

                throw new CompletionException(e);
            }
        });

    } catch (IOException e) {

        return CompletableFuture.failedFuture(
                e
        );
    }
}

28. Why failedFuture() Is Useful

Suppose serialization fails before the HTTP request starts.

We still want to return:

CompletableFuture<R>

instead of throwing immediately.

Java provides:

CompletableFuture.failedFuture(error)

So the caller sees the failure through the same async API.

29. Build an Async RestClient

Let's create a more complete version.

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;

public class AsyncRestClient {

    private final HttpClient client;
    private final ObjectMapper mapper;

    public AsyncRestClient() {

        this.client =
                HttpClient.newBuilder()
                        .connectTimeout(
                                Duration.ofSeconds(10)
                        )
                        .build();

        this.mapper =
                new ObjectMapper()
                        .configure(
                                DeserializationFeature
                                        .FAIL_ON_UNKNOWN_PROPERTIES,
                                false
                        );
    }

    public <T> CompletableFuture<T> getAsync(
            String url,
            Class<T> responseType
    ) {

        HttpRequest request =
                HttpRequest.newBuilder()
                        .uri(
                                URI.create(url)
                        )
                        .timeout(
                                Duration.ofSeconds(20)
                        )
                        .header(
                                "Accept",
                                "application/json"
                        )
                        .GET()
                        .build();

        return client.sendAsync(
                request,
                HttpResponse.BodyHandlers.ofString()
        )
        .thenApply(this::validate)
        .thenApply(
                response ->
                        deserialize(
                                response.body(),
                                responseType
                        )
        );
    }

    private HttpResponse<String> validate(
            HttpResponse<String> response
    ) {

        int status =
                response.statusCode();

        if (status >= 200 &&
            status < 300) {

            return response;
        }

        throw new ApiException(
                status,
                response.body()
        );
    }

    private <T> T deserialize(
            String body,
            Class<T> responseType
    ) {

        try {

            return mapper.readValue(
                    body,
                    responseType
            );

        } catch (IOException e) {

            throw new CompletionException(e);
        }
    }
}

30. Add Authentication

We can add a bearer token:

private final String token;

Constructor:

public AsyncRestClient(
        String token
) {

    this.token = token;

    // initialize client and mapper
}

Then create a shared builder:

private HttpRequest.Builder requestBuilder(
        String url
) {

    HttpRequest.Builder builder =
            HttpRequest.newBuilder()
                    .uri(
                            URI.create(url)
                    )
                    .timeout(
                            Duration.ofSeconds(20)
                    )
                    .header(
                            "Accept",
                            "application/json"
                    );

    if (token != null &&
        !token.isBlank()) {

        builder.header(
                "Authorization",
                "Bearer " + token
        );
    }

    return builder;
}

Then:

HttpRequest request =
        requestBuilder(url)
                .GET()
                .build();

31. Async Retries

Retries can also work with async requests.

A simple recursive pattern:

private CompletableFuture<HttpResponse<String>>
sendWithRetry(
        HttpRequest request,
        int attempt
) {

    return client.sendAsync(
            request,
            HttpResponse.BodyHandlers.ofString()
    )
    .thenCompose(response -> {

        if (!isRetryableStatus(
                response.statusCode()
        )) {

            return CompletableFuture.completedFuture(
                    response
            );
        }

        if (attempt >= 3) {

            return CompletableFuture.completedFuture(
                    response
            );
        }

        return delay(
                Duration.ofSeconds(attempt)
        )
        .thenCompose(
                ignored ->
                        sendWithRetry(
                                request,
                                attempt + 1
                        )
        );
    });
}

Now we need an async delay.

32. Async Delay Without Thread.sleep()

Blocking with:

Thread.sleep(...)

inside async code defeats part of the purpose.

A better option is:

CompletableFuture.delayedExecutor(...)

Example:

private CompletableFuture<Void> delay(
        Duration duration
) {

    return CompletableFuture.runAsync(
            () -> {},
            CompletableFuture.delayedExecutor(
                    duration.toMillis(),
                    TimeUnit.MILLISECONDS
            )
    );
}

Now the retry delay itself does not block the request-handling thread.

33. Async Retry with Exponential Backoff

We can calculate:

private Duration retryDelay(
        int attempt
) {

    long millis =
            1000L *
            (1L << (attempt - 1));

    return Duration.ofMillis(
            millis
    );
}

Then:

return delay(
        retryDelay(attempt)
)
.thenCompose(
        ignored ->
                sendWithRetry(
                        request,
                        attempt + 1
                )
);

This produces:

1 second
2 seconds
4 seconds

34. Async Code Can Become Hard to Read

This is the main downside.

Simple synchronous code:

User user =
        client.getUser();

List<Post> posts =
        client.getPosts();

Async code:

CompletableFuture<User> userFuture =
        client.getUserAsync();

CompletableFuture<List<Post>> postsFuture =
        client.getPostsAsync();

CompletableFuture<UserProfile> profile =
        userFuture.thenCombine(
                postsFuture,
                UserProfile::new
        );

The async version can be faster for independent requests.

But it is also more complex.

So don't use async just because it looks more advanced.

35. Enter Virtual Threads

Modern Java gives us another option:

virtual threads

Virtual threads make blocking code much cheaper than traditional platform threads.

This changes how we think about HTTP concurrency.

Instead of writing async pipelines, we can often write normal blocking code while still achieving high concurrency.

36. Traditional Threads vs Virtual Threads

Traditional threads are relatively expensive.

You generally don't want:

10,000 platform threads

Virtual threads are much lighter.

So this style becomes practical:

try (var executor =
        Executors.newVirtualThreadPerTaskExecutor()) {

    // submit many blocking tasks
}

Each task can call:

client.send(...)

normally.

37. Virtual Thread Example

Suppose we want three HTTP calls.

try (var executor =
        Executors.newVirtualThreadPerTaskExecutor()) {

    Future<User> userFuture =
            executor.submit(
                    () ->
                            getUser()
            );

    Future<List<Post>> postsFuture =
            executor.submit(
                    () ->
                            getPosts()
            );

    Future<List<Todo>> todosFuture =
            executor.submit(
                    () ->
                            getTodos()
            );

    User user =
            userFuture.get();

    List<Post> posts =
            postsFuture.get();

    List<Todo> todos =
            todosFuture.get();
}

These calls run concurrently.

But each method can remain synchronous internally.

38. Why Virtual Threads Are Attractive

Compare these two approaches.

CompletableFuture

userFuture
        .thenCombine(
                postsFuture,
                UserProfile::new
        )
        .thenApply(...)
        .exceptionally(...)

Virtual threads

User user =
        userFuture.get();

List<Post> posts =
        postsFuture.get();

return new UserProfile(
        user,
        posts
);

Virtual threads often let you keep simpler control flow.

39. Does HttpClient Work with Virtual Threads?

Yes.

You can use regular blocking:

client.send(...)

inside a virtual thread.

That is often a very clean design for applications that need high concurrency but prefer straightforward imperative code.

40. CompletableFuture vs Virtual Threads

A rough comparison:

Feature CompletableFuture Virtual Threads
Non-blocking style Yes No, logically blocking
Readability Can become complex Usually simpler
Composition Excellent Straightforward
High concurrency Yes Yes
Best for pipelines Excellent Good
Best for imperative code Okay Excellent
Error handling Functional style Traditional try/catch

Neither approach is always better.

41. When CompletableFuture Is a Good Fit

Use it when:

  • You already have an asynchronous API
  • You need to compose several asynchronous stages
  • You want non-blocking pipelines
  • You need thenCombine(), allOf(), or async transformations
  • Your framework already works heavily with futures

Example:

HTTP request
    ↓
parse response
    ↓
fetch another resource
    ↓
combine results
    ↓
transform

42. When Virtual Threads Are a Good Fit

Virtual threads are often excellent when:

  • Your code is naturally synchronous
  • You want many concurrent blocking operations
  • You want simple try/catch control flow
  • You don't want deeply nested future chains
  • Your application uses lots of I/O

Example:

User user =
        userApi.getUser(id);

List<Post> posts =
        postApi.getPosts(id);

return new Profile(
        user,
        posts
);

This style is easier to debug and understand.

43. Avoid Mixing Styles Without a Reason

You can technically combine:

CompletableFuture
virtual threads
reactive code
blocking code

in one application.

But complexity grows quickly.

Choose the simplest model that fits your needs.

A useful rule:

Simple request
    β†’ synchronous

Many concurrent blocking tasks
    β†’ consider virtual threads

Async pipeline composition
    β†’ consider CompletableFuture

44. Example: Fetch 100 Users Concurrently with CompletableFuture

List<Integer> ids =
        IntStream.rangeClosed(
                1,
                100
        )
        .boxed()
        .toList();

List<CompletableFuture<User>> futures =
        ids.stream()
                .map(
                        id ->
                                client.getAsync(
                                        baseUrl +
                                        "/users/" +
                                        id,
                                        User.class
                                )
                )
                .toList();

List<User> users =
        sequence(futures)
                .join();

This works well.

But remember: concurrency should still be bounded for large workloads.

45. Example: Fetch 100 Users with Virtual Threads

try (var executor =
        Executors.newVirtualThreadPerTaskExecutor()) {

    List<Future<User>> futures =
            ids.stream()
                    .map(
                            id ->
                                    executor.submit(
                                            () ->
                                                    client.get(
                                                            "/users/" +
                                                            id,
                                                            User.class
                                                    )
                                    )
                    )
                    .toList();

    List<User> users =
            new ArrayList<>();

    for (Future<User> future
            : futures) {

        users.add(
                future.get()
        );
    }
}

This is very readable.

46. Partial Failure

Suppose you request:

100 users

and one request fails.

Should the entire operation fail?

Maybe.

Maybe not.

This depends on your application.

All-or-nothing

One failure
    β†’ entire operation fails

Best effort

Successful responses
    β†’ keep them

Failed responses
    β†’ log and continue

You should decide this explicitly.

47. Best-Effort Async Example

CompletableFuture<User> future =
        client.getAsync(
                url,
                User.class
        )
        .handle(
                (user, error) -> {

                    if (error != null) {

                        System.err.println(
                                "Failed: " +
                                url
                        );

                        return null;
                    }

                    return user;
                }
        );

Then filter:

List<User> users =
        sequence(futures)
                .join()
                .stream()
                .filter(
                        Objects::nonNull
                )
                .toList();

48. Preserve Error Information

Returning null loses useful information.

A cleaner approach is a result wrapper.

public record Result<T>(
        T value,
        Throwable error
) {

    public boolean isSuccess() {
        return error == null;
    }
}

Then:

CompletableFuture<Result<User>> future =
        client.getAsync(
                url,
                User.class
        )
        .handle(
                (user, error) ->
                        new Result<>(
                                user,
                                error
                        )
        );

Now every operation produces a result.

49. Use Cases for Concurrent REST Calls

Concurrency is especially useful when:

  • Building dashboards from several APIs
  • Aggregating microservice responses
  • Calling independent third-party services
  • Fetching multiple pages
  • Processing many independent resources
  • Enriching data from several endpoints

For example:

Customer profile
    ↓
Customer API
Orders API
Payments API
Shipping API

If those requests are independent, they can often run concurrently.

50. Don't Parallelize Dependent Requests

Suppose:

Request B needs data from Request A

Then you cannot simply run them together.

Example:

GET /users/10
    ↓
extract accountId
    ↓
GET /accounts/{accountId}

That is a dependency chain.

With CompletableFuture:

CompletableFuture<Account> accountFuture =
        getUserAsync(10)
                .thenCompose(
                        user ->
                                getAccountAsync(
                                        user.accountId()
                                )
                );

Notice:

thenCompose()

not:

thenApply()

51. thenApply() vs thenCompose()

This distinction is important.

Suppose:

getUserAsync()

returns:

CompletableFuture<User>

and:

getAccountAsync(...)

returns:

CompletableFuture<Account>

If you use:

thenApply(...)

you may get:

CompletableFuture<
    CompletableFuture<Account>
>

That's usually not what you want.

Use:

thenCompose(...)

to flatten it into:

CompletableFuture<Account>

52. The Mental Model

A useful rule:

thenApply
    β†’ T becomes R

thenCompose
    β†’ T becomes CompletableFuture<R>
      and Java flattens it

Example:

future.thenApply(
        user ->
                user.name()
);

versus:

future.thenCompose(
        user ->
                getPostsAsync(
                        user.id()
                )
);

53. Full Example: Async User Dashboard

Suppose we have:

public record User(
        int id,
        String name
) {
}
public record Post(
        int id,
        int userId,
        String title
) {
}
public record Todo(
        int id,
        int userId,
        String title,
        boolean completed
) {
}

And:

public record Dashboard(
        User user,
        List<Post> posts,
        List<Todo> todos
) {
}

Then:

CompletableFuture<User> userFuture =
        getUserAsync(userId);

CompletableFuture<List<Post>> postsFuture =
        getPostsAsync(userId);

CompletableFuture<List<Todo>> todosFuture =
        getTodosAsync(userId);

CompletableFuture<Dashboard> dashboardFuture =
        CompletableFuture.allOf(
                userFuture,
                postsFuture,
                todosFuture
        )
        .thenApply(
                ignored ->
                        new Dashboard(
                                userFuture.join(),
                                postsFuture.join(),
                                todosFuture.join()
                        )
        );

Now:

Dashboard dashboard =
        dashboardFuture.join();

54. The Same Idea with Virtual Threads

try (var executor =
        Executors.newVirtualThreadPerTaskExecutor()) {

    Future<User> userFuture =
            executor.submit(
                    () ->
                            getUser(userId)
            );

    Future<List<Post>> postsFuture =
            executor.submit(
                    () ->
                            getPosts(userId)
            );

    Future<List<Todo>> todosFuture =
            executor.submit(
                    () ->
                            getTodos(userId)
            );

    Dashboard dashboard =
            new Dashboard(
                    userFuture.get(),
                    postsFuture.get(),
                    todosFuture.get()
            );
}

Same concurrency.

Different programming model.

55. Which Version Would I Choose?

For this particular example, virtual threads are arguably easier to read.

The control flow is straightforward.

But if the rest of your system already uses asynchronous APIs and futures, CompletableFuture may fit better.

The important thing is not to choose the most complicated option.

Choose the model that makes the code easiest to understand and maintain.

56. Quick CompletableFuture Cheat Sheet

Start an async HTTP request

client.sendAsync(
        request,
        HttpResponse.BodyHandlers.ofString()
);

Transform result

future.thenApply(...)

Chain another async operation

future.thenCompose(...)

Consume result

future.thenAccept(...)

Combine two futures

future1.thenCombine(
        future2,
        (a, b) -> ...
);

Wait for many

CompletableFuture.allOf(
        futures...
);

Recover from error

future.exceptionally(...)

Handle success or failure

future.handle(...)

Observe completion

future.whenComplete(...)

Wait for result

future.join()

57. Quick Virtual Thread Cheat Sheet

Create an executor:

try (var executor =
        Executors.newVirtualThreadPerTaskExecutor()) {

}

Submit a blocking task:

Future<User> future =
        executor.submit(
                () ->
                        getUser()
        );

Get the result:

User user =
        future.get();

This lets you keep synchronous APIs while still achieving a high degree of concurrency.

58. Production Checklist

Before shipping concurrent HTTP code, ask:

  • Are requests independent?
  • Is concurrency bounded?
  • Are timeouts configured?
  • Are rate limits respected?
  • Are retries bounded?
  • Are errors preserved?
  • Is partial failure acceptable?
  • Are async exceptions handled?
  • Are POST retries safe?
  • Are large batches controlled?
  • Is the code still readable?
  • Would virtual threads make it simpler?

Concurrency can improve performance, but uncontrolled concurrency can create new problems.

Final Thoughts

Java now gives us several strong options for HTTP concurrency.

You can use:

HttpClient.sendAsync()
        +
CompletableFuture

for asynchronous pipelines.

Or:

virtual threads
        +
blocking HttpClient.send()

for high-concurrency code that still looks synchronous.

The key is understanding the tradeoff.

CompletableFuture is excellent when your workflow is naturally asynchronous and composed from multiple stages.

Virtual threads are excellent when you want simple blocking code but still need lots of concurrent I/O.

A useful progression is:

Simple request
    ↓
Synchronous HttpClient

Several independent requests
    ↓
CompletableFuture or virtual threads

Large-scale concurrent I/O
    ↓
Bounded concurrency + timeouts + retries + rate limits

The goal is not to make your code "more asynchronous."

The goal is to make it faster where concurrency helps, while keeping the code understandable.

About the Author

Deividas Strole is a Full-Stack Developer based in California, specializing in Java, Spring Boot, JavaScript, React, SQL, and AI-powered applications. He writes about software engineering, modern full-stack development, and digital marketing.

Connect with me:

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.