HttpClient.sendAsync()
Send a request without blocking and get a CompletableFuture of the response, then combine, transform and recover from calls in a pipeline.
<T> CompletableFuture<HttpResponse<T>> sendAsync(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)- Added in Java Java 11
Parameters
| Parameter | Type | Description |
|---|---|---|
$request | HttpRequest | The request to send. |
$responseBodyHandler | HttpResponse.BodyHandler<T> | Decides what the response body becomes. |
Return value
CompletableFuture<HttpResponse<T>>A future that completes with the response once the body has been handled, or completes exceptionally with an IOException (such as HttpTimeoutException) if no response could be obtained.
Errors
IllegalArgumentException if the request is not valid for this client. Network failures and timeouts are not thrown; they complete the future exceptionally, and join() then throws CompletionException (get() throws ExecutionException) with the IOException as the cause.
client.sendAsync(request, bodyHandler) returns at once with a CompletableFuture<HttpResponse<T>>. The exchange runs in the background, and the future completes when the body has been handled. There is also an overload taking a PushPromiseHandler, for HTTP/2 server push.
Composing calls
The point of the future is what you chain on it:
thenApply(HttpResponse::body)to transform a result.thenCompose(...)when the next call depends on this one's answer.CompletableFuture.allOf(...)to wait for several calls started together.exceptionally(...)orhandle(...)to turn a failure into a fallback value.
Starting every call before joining any of them is what makes them run in parallel. A loop that calls sendAsync(...).join() one at a time is just a slower way of writing send.
Where failures go
Network errors and timeouts do not throw from sendAsync. They complete the future exceptionally. join() then throws an unchecked CompletionException and get() a checked ExecutionException, with the real IOException or HttpTimeoutException as the cause. Unwrap with getCause() before deciding what happened. As with send, a 404 or 500 is a successful completion, not a failure.
Timeouts
Set timeout() on the request; the future then fails with HttpTimeoutException. orTimeout(...) on the future is a different thing: it completes the future with a TimeoutException after the delay, which bounds a whole chain but is not a setting the HTTP client enforces itself. Prefer the request timeout for each call and use orTimeout for an overall deadline, which also covers a slow body that the request timeout stops watching once the headers arrive.
Threads
Dependent stages such as thenApply run on whichever thread completes the future, often one of the client's executor threads. Keep them short and non-blocking, or pass an executor to the ...Async variants. Since Java 21 virtual threads make the blocking send a simpler choice in many services; sendAsync still fits code that is already built around CompletableFuture, or event-loop code that must never block.
Example
When to use it
- Fetching exchange rates for several currencies in parallel before pricing a basket.
- Showing a default list of products when a recommendations service misses its 300 ms timeout.
- Looking up an order and then its customer in a non-blocking pipeline inside an event-driven service.
Frequently asked questions
How do I send multiple HTTP requests in parallel in Java?
Call sendAsync() for every request first and keep the futures, then wait with CompletableFuture.allOf(...).join() or join each future in turn. On Java 21 and later you can instead submit blocking send() calls to Executors.newVirtualThreadPerTaskExecutor().
Why does join() throw CompletionException instead of IOException?
join() is unchecked, so it wraps the failure in a CompletionException. The original IOException or HttpTimeoutException is available from getCause(). get() wraps it in ExecutionException instead.
Does sendAsync fail the future on a 500 response?
No. Any HTTP status completes the future normally with the response. Check statusCode() in a thenApply stage and throw there if you want non-2xx statuses to become failures.
More examples
Unwrapping a failure and falling back
In <code>exceptionally</code> the exception arrives wrapped in a <code>CompletionException</code> here, so the code checks <code>getCause()</code>. A slow recommendations service then degrades to a default list instead of failing the page.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
void main() throws Exception {
CountDownLatch release = new CountDownLatch(1);
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/recommendations", exchange -> {
try {
release.await(); // answers only after the client has given up
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
exchange.sendResponseHeaders(204, -1);
exchange.close();
});
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
HttpRequest request = HttpRequest.newBuilder(
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/recommendations"))
.timeout(Duration.ofMillis(300))
.build();
try (HttpClient client = HttpClient.newHttpClient()) {
CompletableFuture<HttpResponse<String>> call = client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
try {
call.join();
} catch (CompletionException e) {
IO.println("join threw " + e.getClass().getSimpleName());
IO.println("cause: " + e.getCause().getClass().getSimpleName() + ": " + e.getCause().getMessage());
}
String shown = client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.exceptionally(e -> e.getCause() instanceof HttpTimeoutException ? "[bestsellers]" : "[]")
.join();
IO.println("shown: " + shown);
} finally {
release.countDown();
server.stop(0);
}
}
// Output:
// join threw CompletionException
// cause: HttpTimeoutException: request timed out
// shown: [bestsellers]One call that depends on another
<code>thenCompose</code> flattens the second future, so the result is a <code>CompletableFuture<String></code> rather than a future of a future. The status check inside <code>thenApply</code> turns a non-200 into a failed future, because <code>sendAsync</code> treats every status as success.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// Writes a small text response; an empty body is sent with no body at all.
void reply(HttpExchange exchange, int status, String contentType, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
if (contentType != null) {
exchange.getResponseHeaders().set("Content-Type", contentType);
}
exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(bytes);
}
}
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/orders/77", exchange -> reply(exchange, 200, "text/plain", "customer=c-9"));
server.createContext("/customers/c-9", exchange -> reply(exchange, 200, "text/plain", "Grace Hopper"));
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
Function<String, CompletableFuture<String>> get = path -> client
.sendAsync(HttpRequest.newBuilder(URI.create(base + path)).build(), HttpResponse.BodyHandlers.ofString())
.thenApply(response -> {
if (response.statusCode() != 200) {
throw new IllegalStateException(path + " returned " + response.statusCode());
}
return response.body();
});
String name = get.apply("/orders/77")
.thenApply(body -> body.substring("customer=".length()))
.thenCompose(customerId -> get.apply("/customers/" + customerId))
.join();
IO.println("order 77 belongs to " + name);
} finally {
server.stop(0);
}
}
// Output:
// order 77 belongs to Grace HopperAn overall deadline with orTimeout
The failure is a plain <code>java.util.concurrent.TimeoutException</code> from the future, not the client's <code>HttpTimeoutException</code>. <code>shutdownNow()</code> (Java 21) then asks the client to abort the exchange the program stopped waiting for, where <code>close()</code> would wait for it to finish.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
CountDownLatch release = new CountDownLatch(1);
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/export", exchange -> {
try {
release.await(); // answers only after the client has given up
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
exchange.sendResponseHeaders(204, -1);
exchange.close();
});
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/export");
HttpClient client = HttpClient.newHttpClient();
CompletableFuture<Integer> status = client
.sendAsync(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.discarding())
.thenApply(HttpResponse::statusCode)
.orTimeout(300, TimeUnit.MILLISECONDS);
try {
status.join();
} catch (CompletionException e) {
IO.println("gave up: " + e.getCause().getClass().getName());
}
client.shutdownNow();
release.countDown();
server.stop(0);
}
// Output:
// gave up: java.util.concurrent.TimeoutException
Discussion