Asynchronous HTTP
In the previous lesson you used HttpClient.send() — a blocking call that pins the calling thread until the server responds. For a handful of sequential requests that is perfectly fine. But when you need to fire dozens of requests concurrently, chain results together, or keep a UI or server thread unblocked, blocking is the wrong model. HttpClient.sendAsync() is the answer: it returns immediately with a CompletableFuture<HttpResponse<T>> that you compose non-blocking using the full power of the CompletableFuture API you studied in the concurrency lessons.
sendAsync: The Basics
The signature of sendAsync mirrors send exactly — it takes the same HttpRequest and BodyHandler — but it returns a future instead of blocking:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.concurrent.CompletableFuture;
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/v1/products/42"))
.header("Accept", "application/json")
.build();
CompletableFuture<HttpResponse<String>> future =
client.sendAsync(request, BodyHandlers.ofString());
// The calling thread is free to do other work here.
// When the response arrives the future completes on the client's executor.
HttpResponse<String> response = future.join(); // block only when you must
System.out.println(response.statusCode());
System.out.println(response.body());
join() vs get() — Both block until the future completes. join() is preferred in non-blocking pipelines because it rethrows exceptions as unchecked CompletionException rather than checked ExecutionException, keeping lambda bodies clean.
Composing the Future: thenApply, thenAccept, thenCompose
The real power is that you never have to block at all. Instead, you chain callbacks that run as soon as each stage completes. The three most important composition methods are:
- thenApply(Function) — transform the result synchronously; returns a new
CompletableFuture of the mapped type.
- thenAccept(Consumer) — consume the result (side effect); returns
CompletableFuture<Void>.
- thenCompose(Function) — flat-map: when the callback itself returns a
CompletableFuture, this avoids nesting (the async equivalent of flatMap).
A practical example — fetch a product, parse the name, then log it without ever explicitly blocking:
client.sendAsync(request, BodyHandlers.ofString())
.thenApply(HttpResponse::body) // extract body: CF<String>
.thenApply(body -> parseProductName(body)) // transform: CF<String>
.thenAccept(name -> System.out.println("Product: " + name)) // consume
.exceptionally(ex -> {
System.err.println("Request failed: " + ex.getMessage());
return null;
});
Firing Multiple Requests Concurrently
This is where sendAsync truly shines. Sending N requests sequentially takes N * RTT time. Sending them concurrently takes roughly 1 * RTT (plus parallelism overhead). Use Stream to build the futures, then CompletableFuture.allOf() to wait for all of them:
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
List<String> productIds = List.of("42", "99", "107", "221");
// 1. Fire all requests concurrently
List<CompletableFuture<String>> futures = productIds.stream()
.map(id -> HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/v1/products/" + id))
.build())
.map(req -> client.sendAsync(req, BodyHandlers.ofString())
.thenApply(HttpResponse::body))
.collect(Collectors.toList());
// 2. Combine: wait for ALL to complete, then collect results
CompletableFuture<Void> allDone = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
List<String> bodies = allDone
.thenApply(v -> futures.stream()
.map(CompletableFuture::join) // join is safe here — all are already done
.collect(Collectors.toList()))
.join();
bodies.forEach(System.out::println);
allOf + join() is idiomatic. After allOf(...).join() returns, every individual future is guaranteed to be complete, so calling .join() on each one never blocks — it just reads the cached result.
anyOf: First Successful Response Wins
Sometimes you want whichever of several equivalent endpoints responds first — a hedged request pattern used for resiliency:
HttpRequest mirror1 = HttpRequest.newBuilder()
.uri(URI.create("https://cdn-eu.example.com/data.json")).build();
HttpRequest mirror2 = HttpRequest.newBuilder()
.uri(URI.create("https://cdn-us.example.com/data.json")).build();
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
client.sendAsync(mirror1, BodyHandlers.ofString()).thenApply(HttpResponse::body),
client.sendAsync(mirror2, BodyHandlers.ofString()).thenApply(HttpResponse::body)
);
String result = (String) fastest.join();
Note that anyOf returns CompletableFuture<Object> (no generic type) because Java's type system cannot unify different future types. You cast the result. The other in-flight request continues in the background and is eventually garbage-collected when its future is no longer referenced.
Error Handling in Async Pipelines
Network errors (connection refused, timeout) and non-2xx status codes must both be handled. sendAsync itself only fails the future for transport errors — a 404 or 500 from the server is a successful future with a bad status code. You need to check both:
client.sendAsync(request, BodyHandlers.ofString())
.thenApply(response -> {
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new RuntimeException(
"HTTP error: " + response.statusCode() + " for " + request.uri());
}
return response.body();
})
.exceptionally(ex -> {
// Handles both transport failures AND our thrown RuntimeException
System.err.println("Failed: " + ex.getCause().getMessage());
return "{}"; // return a safe default to keep the pipeline alive
})
.thenAccept(body -> processBody(body));
exceptionally() swallows and recovers. If you want to propagate the error rather than recover, use whenComplete() or handle() instead — they let you inspect both result and exception and re-throw if needed. Calling exceptionally() that returns a non-null value turns a failed stage into a successful one.
Controlling the Executor
By default, sendAsync completion callbacks run on the client's built-in executor (a small fork-join pool). In a server application you often want callbacks on a specific thread pool — for example, the same virtual-thread executor you configured on the client:
import java.util.concurrent.Executors;
HttpClient client = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor()) // Java 21+
.build();
// Callbacks now run on virtual threads — cheap, non-blocking, and scalable
client.sendAsync(request, BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(body -> processBody(body));
Alternatively, suffix any stage with Async — thenApplyAsync(fn, executor) — to move that specific stage onto a chosen pool without changing the whole client.
Putting It Together: A Real-World Pattern
A typical production pattern: fetch a list of IDs from one endpoint, then concurrently enrich each ID from a detail endpoint, and collect only successful results:
import java.util.Optional;
// Step 1: fetch IDs (blocking is acceptable here — it is one call)
List<String> ids = fetchIdList(client); // synchronous helper
// Step 2: enrich concurrently
List<CompletableFuture<Optional<Product>>> enrichFutures = ids.stream()
.map(id -> client
.sendAsync(detailRequest(id), BodyHandlers.ofString())
.thenApply(r -> r.statusCode() == 200
? Optional.of(parseProduct(r.body()))
: Optional.<Product>empty())
.exceptionally(ex -> Optional.empty())) // network failure → empty
.collect(Collectors.toList());
// Step 3: wait for all, filter successes
List<Product> products = CompletableFuture
.allOf(enrichFutures.toArray(new CompletableFuture[0]))
.thenApply(v -> enrichFutures.stream()
.map(CompletableFuture::join)
.filter(Optional::isPresent)
.map(Optional::get)
.collect(Collectors.toList()))
.join();
Summary
sendAsync() returns a CompletableFuture immediately, freeing the calling thread. Compose it with thenApply, thenCompose, and thenAccept for transformations; use allOf to fan out and gather, and anyOf for hedged requests. Always handle both transport errors and non-2xx status codes explicitly. On Java 21+ pair with a virtual-thread executor for maximum throughput with minimal configuration.