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
CompletableFuturewith 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:
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.