the http client
HttpClient, async requests, http/2 and http/3.
- chapter
- chapter 10.07
- reading time
- 8 min read
java.net.http.HttpClient java 11+ is the JDK's built-in HTTP client. you build an immutable HttpRequest, hand it to a client with a BodyHandler (what to turn the body into), and get an HttpResponse<T> back, either blocking with send or as a CompletableFuture with sendAsync.
it speaks HTTP/1.1 and HTTP/2 out of the box, and HTTP/3 since java 26. make one client and reuse it.
the picture
you know how a restaurant works? you write your order on a slip (the HttpRequest: method, URL, headers, body), hand it to the waiter (the HttpClient, who knows the kitchen and keeps the doors open between trips), and say how you want it served (the BodyHandler: as a string, into a file, as a stream). you can stand at the counter until it's ready (send), or take a buzzer and sit down (sendAsync).
hover a stage.
examples that call the real internet can't run in a checker (or on a plane), so this chapter brings its own server: com.sun.net.httpserver.HttpServer, a small HTTP/1.1 server that has shipped in the JDK since java 6. everything below runs against localhost:
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.*;
void main() throws Exception {
// a tiny local server, so this runs without the internet
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/hello", exchange -> {
String upgrade = exchange.getRequestHeaders().getFirst("Upgrade");
byte[] body = ("hi! you asked to upgrade to: " + upgrade).getBytes();
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) { out.write(body); }
});
server.start();
URI uri = URI.create("http://localhost:" + server.getAddress().getPort() + "/hello");
try (HttpClient client = HttpClient.newHttpClient()) {
HttpRequest request = HttpRequest.newBuilder(uri).GET().build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
IO.println(response.statusCode() + " " + response.version());
IO.println(response.body());
}
server.stop(0);
}200 HTTP_1_1 hi! you asked to upgrade to: h2c
this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.
look at that second line. the client prefers HTTP/2 by default, so on a plain http:// URL it sends an Upgrade: h2c header ("can we switch to cleartext HTTP/2?"). the JDK's little server doesn't do HTTP/2, ignores it, and answers in HTTP/1.1, so the response says HTTP_1_1. on https:// the negotiation happens inside the TLS handshake instead (ALPN), and most real servers say yes.
HttpClient is AutoCloseable since java 21, which is why it sits in a try-with-resources here. in a real app you'd build one at startup and close it at shutdown.
sending things
a POST is the same builder with a BodyPublisher. timeouts come in two flavours: connectTimeout on the client (how long to wait for TCP and TLS), and timeout on the request (how long to wait for the response headers):
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/orders", exchange -> {
String json = new String(exchange.getRequestBody().readAllBytes());
String type = exchange.getRequestHeaders().getFirst("Content-Type");
byte[] reply = ("created " + json + " as " + type).getBytes();
exchange.getResponseHeaders().add("Location", "/orders/42");
exchange.sendResponseHeaders(201, reply.length);
try (var out = exchange.getResponseBody()) { out.write(reply); }
});
server.createContext("/slow", exchange -> {
try { Thread.sleep(2000); } catch (InterruptedException e) { }
exchange.sendResponseHeaders(204, -1);
exchange.close();
});
server.start();
String base = "http://localhost:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build()) {
HttpRequest post = HttpRequest.newBuilder(URI.create(base + "/orders"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"item\":\"chai\",\"qty\":2}"))
.build();
HttpResponse<String> r = client.send(post, HttpResponse.BodyHandlers.ofString());
IO.println(r.statusCode() + " " + r.headers().firstValue("Location").orElse("?"));
IO.println(r.body());
HttpRequest slow = HttpRequest.newBuilder(URI.create(base + "/slow"))
.timeout(Duration.ofMillis(200))
.build();
try {
client.send(slow, HttpResponse.BodyHandlers.discarding());
} catch (HttpTimeoutException e) {
IO.println(e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
server.stop(0);
}201 /orders/42
created {"item":"chai","qty":2} as application/json
HttpTimeoutException: request timed outthis one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.
sync or async
send blocks the calling thread until the response is in. sendAsync returns a CompletableFuture immediately and finishes the work on the client's own threads. one request, no difference. many requests, a huge one:
send() blocks until the whole response is in. in a loop, the latencies add up: five round trips, one after another.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.*;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/slow", exchange -> {
try { Thread.sleep(300); } catch (InterruptedException e) { } // a 300 ms backend
byte[] body = exchange.getRequestURI().getQuery().getBytes();
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) { out.write(body); }
});
server.start();
String base = "http://localhost:" + server.getAddress().getPort() + "/slow?";
List<HttpRequest> requests = IntStream.range(0, 5)
.mapToObj(i -> HttpRequest.newBuilder(URI.create(base + "r" + i)).build())
.toList();
try (HttpClient client = HttpClient.newHttpClient()) {
long t0 = System.nanoTime();
for (var r : requests) client.send(r, HttpResponse.BodyHandlers.ofString());
long sync = System.nanoTime() - t0;
t0 = System.nanoTime();
List<CompletableFuture<String>> futures = requests.stream()
.map(r -> client.sendAsync(r, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body))
.toList();
List<String> bodies = futures.stream().map(CompletableFuture::join).toList();
long async = System.nanoTime() - t0;
IO.println(bodies);
IO.println("async was at least 3x faster: " + (async * 3 < sync));
}
server.stop(0);
}[r0, r1, r2, r3, r4] async was at least 3x faster: true
this one needs a real JVM (virtual threads), so it doesn't run in the browser. copy it into java Main.java to try it.
the futures compose like any other CompletableFuture: thenApply to parse, thenCompose to chain a second call, allOf to wait for a batch. and since java 21 there's a third option that reads like the sync version: call send from many virtual threads. blocking a virtual thread is cheap, so plain loops get async-like throughput.
for (int i = 0; i < 3; i++) {
HttpClient.newHttpClient(); // and never closed
}
long n = Thread.getAllStackTraces().keySet().stream()
.filter(t -> t.getName().contains("SelectorManager")).count();
IO.println(n);http/1.1, http/2, http/3
the client picks the protocol for you (HTTP/2 when the server agrees, otherwise HTTP/1.1), but the difference shows up in latency the moment you make several requests to one host.
frames from all three responses share one connection, interleaved. nobody waits for anybody. one TCP connection, one TLS handshake.
| version | transport | in the JDK client | what it fixed |
|---|---|---|---|
| HTTP/1.1 | TCP | since 11 | one request at a time per connection; parallelism means more connections |
| HTTP/2 | TCP (+TLS) | since 11, the default | many streams on one connection, binary frames, compressed headers |
| HTTP/3 | QUIC over UDP | java 26+ opt-in | no TCP head-of-line blocking, faster handshakes |
HTTP/3 came in java 26 (JEP 517) and stays opt-in: the default is still HTTP/2. you ask for it on the client or per request, and optionally say how the client should find out the server speaks it:
// java 26+. not runnable on 25
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.build();
HttpRequest req = HttpRequest.newBuilder(URI.create("https://example.com/"))
.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.ALT_SVC)
.build();there are three discovery modes. ANY (what you get if you don't set one) lets the client try QUIC and TCP side by side and keep whichever wins. ALT_SVC starts on HTTP/2 or 1.1 and switches once the server advertises HTTP/3 in an Alt-Svc header. HTTP_3_URI_ONLY goes straight to HTTP/3 and fails if the server doesn't speak it. HTTP/3 doesn't go through proxies: with one configured, the client quietly drops back to HTTP/2.
a server for when you need one
the JDK ships two servers you can use without a framework. com.sun.net.httpserver.HttpServer, used all over this chapter, for tests and tiny tools. and since java 18, jwebserver (JEP 408): a command-line static file server for prototyping, demos and serving a folder to your phone. real output from jdk 25:
$ jwebserver -p 8765
Binding to loopback by default. For all interfaces use "-b 0.0.0.0" or "-b ::".
Serving /Users/ada/site and subdirectories on 127.0.0.1 port 8765
URL http://127.0.0.1:8765/
127.0.0.1 - - [09/Oct/2026:09:53:51 +0530] "GET /index.html HTTP/1.1" 200 -it's HTTP/1.1 only, serves files and nothing else, and deliberately has no TLS. not for production, and it says so.
why HttpClient and not HttpURLConnection+
HttpURLConnection dates from java 1.1: one blocking call per request, a mutable object you configure by side effect, no HTTP/2, a URL-based API that throws IOException for a 404 when you read the body, and handlers chosen through global factories. HttpClient was written from scratch on NIO, incubated in java 9 and became standard in java 11 (JEP 321). it's immutable, async-first, HTTP/2 native, and uses the Flow reactive-streams interfaces for bodies, which is why you can stream a gigabyte download into a file without holding it in memory (BodyHandlers.ofFile(path)).
HttpRequestis immutable,HttpClientis thread-safe with a pool: build one, reuse it.- a
BodyHandlerdecides what the body becomes: string, bytes, file, lines, stream. sendblocks,sendAsyncreturns aCompletableFuture. virtual threads make blockingsendcheap too.- HTTP/2 is the default; HTTP/3 (java 26, JEP 517) is opt-in.
- 404s don't throw, and there's no default timeout. check status, set timeouts.