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

Related posts