DEV Community

Cover image for Building an Async Java REST Client with CompletableFuture, Concurrent Requests, and Virtual Threads
Deividas Strole
Deividas Strole

Posted on Edited on

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 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()
);
Enter fullscreen mode Exit fullscreen mode

The current thread waits until the response arrives.

Conceptually:

Send request
    ↓
Wait
    ↓
Wait
    ↓
Receive response
    ↓
Continue
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

2 + 3 + 1 = 6 seconds
Enter fullscreen mode Exit fullscreen mode

That is where concurrency can help.


2. HttpClient.sendAsync()

Java's HttpClient has an asynchronous method:

sendAsync()
Enter fullscreen mode Exit fullscreen mode

Instead of returning:

HttpResponse<String>
Enter fullscreen mode Exit fullscreen mode

it returns:

CompletableFuture<HttpResponse<String>>
Enter fullscreen mode Exit fullscreen mode

Example:

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

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

The important line is:

future.thenAccept(...)
Enter fullscreen mode Exit fullscreen mode

This says:

When the response arrives, run this code.


4. What Does join() Do?

In the previous example, we used:

future.join();
Enter fullscreen mode Exit fullscreen mode

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()
    );
});
Enter fullscreen mode Exit fullscreen mode

thenApply()

Use it when you want to transform the result.

CompletableFuture<String> bodyFuture =
        future.thenApply(
                HttpResponse::body
        );
Enter fullscreen mode Exit fullscreen mode

Now:

bodyFuture
Enter fullscreen mode Exit fullscreen mode

is:

CompletableFuture<String>
Enter fullscreen mode Exit fullscreen mode

rather than:

CompletableFuture<HttpResponse<String>>
Enter fullscreen mode Exit fullscreen mode

6. Transforming an HTTP Response

A common pattern is:

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

Then:

String body = result.join();
Enter fullscreen mode Exit fullscreen mode

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()
    );
}
Enter fullscreen mode Exit fullscreen mode

Now:

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

The flow becomes:

sendAsync()
    ↓
validate()
    ↓
extract body
Enter fullscreen mode Exit fullscreen mode

8. Add Jackson

Now let's combine async HTTP with Jackson.

Suppose an API returns:

{
  "id": 1,
  "title": "Hello",
  "body": "Example",
  "userId": 1
}
Enter fullscreen mode Exit fullscreen mode

Create a record:

public record Post(
        int id,
        String title,
        String body,
        int userId
) {
}
Enter fullscreen mode Exit fullscreen mode

Then use Jackson:

ObjectMapper mapper =
        new ObjectMapper();
Enter fullscreen mode Exit fullscreen mode

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);
            }
        });
Enter fullscreen mode Exit fullscreen mode

Now:

postFuture
Enter fullscreen mode Exit fullscreen mode

is:

CompletableFuture<Post>
Enter fullscreen mode Exit fullscreen mode

Usage:

Post post =
        postFuture.join();

System.out.println(
        post.title()
);
Enter fullscreen mode Exit fullscreen mode

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);
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Now calling an API becomes:

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

11. Why CompletionException?

Inside a CompletableFuture pipeline, checked exceptions are awkward.

Jackson can throw:

IOException
Enter fullscreen mode Exit fullscreen mode

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

A common pattern is:

throw new CompletionException(e);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        );
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Concurrent:

Request A ──────────────┐
Request B ──────────┐   │
Request C ───────┐  │   │
                 ↓  ↓   ↓
               results arrive
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

and:

CompletableFuture<List<Post>>
Enter fullscreen mode Exit fullscreen mode

We can use:

thenCombine()
Enter fullscreen mode Exit fullscreen mode

Example:

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

Now:

profileFuture
Enter fullscreen mode Exit fullscreen mode

contains both results.


15. Example UserProfile

public record UserProfile(
        User user,
        List<Post> posts
) {
}
Enter fullscreen mode Exit fullscreen mode

Then:

UserProfile profile =
        profileFuture.join();
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

Example:

CompletableFuture<User> userFuture =
        getUser();

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

CompletableFuture<List<Todo>> todosFuture =
        getTodos();
Enter fullscreen mode Exit fullscreen mode

Then:

CompletableFuture<Void> all =
        CompletableFuture.allOf(
                userFuture,
                postsFuture,
                todosFuture
        );
Enter fullscreen mode Exit fullscreen mode

Wait for all:

all.join();
Enter fullscreen mode Exit fullscreen mode

Then retrieve:

User user =
        userFuture.join();

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

List<Todo> todos =
        todosFuture.join();
Enter fullscreen mode Exit fullscreen mode

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()
                )
        );
Enter fullscreen mode Exit fullscreen mode

Now:

Dashboard dashboard =
        dashboardFuture.join();
Enter fullscreen mode Exit fullscreen mode

18. A Helper for Lists of Futures

Suppose we have:

List<CompletableFuture<User>>
Enter fullscreen mode Exit fullscreen mode

and want:

CompletableFuture<List<User>>
Enter fullscreen mode Exit fullscreen mode

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()
    );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

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

CompletableFuture<List<User>> usersFuture =
        sequence(futures);
Enter fullscreen mode Exit fullscreen mode

This is a very useful pattern.


19. But Don't Launch Unlimited Requests

There is an important danger.

Imagine:

10,000 user IDs
Enter fullscreen mode Exit fullscreen mode

and you do:

ids.stream()
        .map(id -> getAsync(...))
        .toList();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

do:

50 at a time
Enter fullscreen mode Exit fullscreen mode

For example:

int batchSize = 50;
Enter fullscreen mode Exit fullscreen mode

Then process IDs in batches.

Conceptually:

Batch 1
    → 50 requests
    → wait

Batch 2
    → 50 requests
    → wait

Batch 3
    → 50 requests
Enter fullscreen mode Exit fullscreen mode

This is often safer than unlimited concurrency.


21. Async Error Handling with exceptionally()

Suppose a request fails.

You can handle it using:

exceptionally()
Enter fullscreen mode Exit fullscreen mode

Example:

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

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

            return null;
        });
Enter fullscreen mode Exit fullscreen mode

This converts the failed future into a successful future containing:

null
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example:

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

            if (error != null) {

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

                return fallbackUser();
            }

            return user;
        }
);
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Example:

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

            if (error != null) {

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

            } else {

                System.out.println(
                        "Request completed"
                );
            }
        }
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

25. Add Async Timeouts

CompletableFuture provides timeout helpers.

Fail after a timeout

future.orTimeout(
        5,
        TimeUnit.SECONDS
);
Enter fullscreen mode Exit fullscreen mode

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

Return a fallback value

future.completeOnTimeout(
        fallbackUser,
        5,
        TimeUnit.SECONDS
);
Enter fullscreen mode Exit fullscreen mode

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)
        )
Enter fullscreen mode Exit fullscreen mode

CompletableFuture.orTimeout(...)

Controls the future pipeline.

future.orTimeout(
        12,
        TimeUnit.SECONDS
);
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

28. Why failedFuture() Is Useful

Suppose serialization fails before the HTTP request starts.

We still want to return:

CompletableFuture<R>
Enter fullscreen mode Exit fullscreen mode

instead of throwing immediately.

Java provides:

CompletableFuture.failedFuture(error)
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

30. Add Authentication

We can add a bearer token:

private final String token;
Enter fullscreen mode Exit fullscreen mode

Constructor:

public AsyncRestClient(
        String token
) {

    this.token = token;

    // initialize client and mapper
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Then:

HttpRequest request =
        requestBuilder(url)
                .GET()
                .build();
Enter fullscreen mode Exit fullscreen mode

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
                        )
        );
    });
}
Enter fullscreen mode Exit fullscreen mode

Now we need an async delay.


32. Async Delay Without Thread.sleep()

Blocking with:

Thread.sleep(...)
Enter fullscreen mode Exit fullscreen mode

inside async code defeats part of the purpose.

A better option is:

CompletableFuture.delayedExecutor(...)
Enter fullscreen mode Exit fullscreen mode

Example:

private CompletableFuture<Void> delay(
        Duration duration
) {

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

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
    );
}
Enter fullscreen mode Exit fullscreen mode

Then:

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

This produces:

1 second
2 seconds
4 seconds
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Async code:

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

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

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Virtual threads are much lighter.

So this style becomes practical:

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

    // submit many blocking tasks
}
Enter fullscreen mode Exit fullscreen mode

Each task can call:

client.send(...)
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

Virtual threads

User user =
        userFuture.get();

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

return new UserProfile(
        user,
        posts
);
Enter fullscreen mode Exit fullscreen mode

Virtual threads often let you keep simpler control flow.


39. Does HttpClient Work with Virtual Threads?

Yes.

You can use regular blocking:

client.send(...)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

This is very readable.


46. Partial Failure

Suppose you request:

100 users
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Best effort

Successful responses
    → keep them

Failed responses
    → log and continue
Enter fullscreen mode Exit fullscreen mode

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;
                }
        );
Enter fullscreen mode Exit fullscreen mode

Then filter:

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

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then:

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

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
Enter fullscreen mode Exit fullscreen mode

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


50. Don't Parallelize Dependent Requests

Suppose:

Request B needs data from Request A
Enter fullscreen mode Exit fullscreen mode

Then you cannot simply run them together.

Example:

GET /users/10
    ↓
extract accountId
    ↓
GET /accounts/{accountId}
Enter fullscreen mode Exit fullscreen mode

That is a dependency chain.

With CompletableFuture:

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

Notice:

thenCompose()
Enter fullscreen mode Exit fullscreen mode

not:

thenApply()
Enter fullscreen mode Exit fullscreen mode

51. thenApply() vs thenCompose()

This distinction is important.

Suppose:

getUserAsync()
Enter fullscreen mode Exit fullscreen mode

returns:

CompletableFuture<User>
Enter fullscreen mode Exit fullscreen mode

and:

getAccountAsync(...)
Enter fullscreen mode Exit fullscreen mode

returns:

CompletableFuture<Account>
Enter fullscreen mode Exit fullscreen mode

If you use:

thenApply(...)
Enter fullscreen mode Exit fullscreen mode

you may get:

CompletableFuture<
    CompletableFuture<Account>
>
Enter fullscreen mode Exit fullscreen mode

That's usually not what you want.

Use:

thenCompose(...)
Enter fullscreen mode Exit fullscreen mode

to flatten it into:

CompletableFuture<Account>
Enter fullscreen mode Exit fullscreen mode

52. The Mental Model

A useful rule:

thenApply
    → T becomes R

thenCompose
    → T becomes CompletableFuture<R>
      and Java flattens it
Enter fullscreen mode Exit fullscreen mode

Example:

future.thenApply(
        user ->
                user.name()
);
Enter fullscreen mode Exit fullscreen mode

versus:

future.thenCompose(
        user ->
                getPostsAsync(
                        user.id()
                )
);
Enter fullscreen mode Exit fullscreen mode

53. Full Example: Async User Dashboard

Suppose we have:

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

And:

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

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()
                        )
        );
Enter fullscreen mode Exit fullscreen mode

Now:

Dashboard dashboard =
        dashboardFuture.join();
Enter fullscreen mode Exit fullscreen mode

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()
            );
}
Enter fullscreen mode Exit fullscreen mode

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()
);
Enter fullscreen mode Exit fullscreen mode

Transform result

future.thenApply(...)
Enter fullscreen mode Exit fullscreen mode

Chain another async operation

future.thenCompose(...)
Enter fullscreen mode Exit fullscreen mode

Consume result

future.thenAccept(...)
Enter fullscreen mode Exit fullscreen mode

Combine two futures

future1.thenCombine(
        future2,
        (a, b) -> ...
);
Enter fullscreen mode Exit fullscreen mode

Wait for many

CompletableFuture.allOf(
        futures...
);
Enter fullscreen mode Exit fullscreen mode

Recover from error

future.exceptionally(...)
Enter fullscreen mode Exit fullscreen mode

Handle success or failure

future.handle(...)
Enter fullscreen mode Exit fullscreen mode

Observe completion

future.whenComplete(...)
Enter fullscreen mode Exit fullscreen mode

Wait for result

future.join()
Enter fullscreen mode Exit fullscreen mode

57. Quick Virtual Thread Cheat Sheet

Create an executor:

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

}
Enter fullscreen mode Exit fullscreen mode

Submit a blocking task:

Future<User> future =
        executor.submit(
                () ->
                        getUser()
        );
Enter fullscreen mode Exit fullscreen mode

Get the result:

User user =
        future.get();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

for asynchronous pipelines.

Or:

virtual threads
        +
blocking HttpClient.send()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)