HttpClient: The Modern java.net.http API
Objective
HttpClient (in java.net.http, since Java 11) replaced the old HttpURLConnection with a builder-based API purpose-built for HTTP: a request and its configuration are assembled with fluent builders instead of mutating a connection object property-by-property, the client speaks HTTP/2 by default, and every request can be sent either synchronously (send(), blocking) or asynchronously (sendAsync(), returning a CompletableFuture).
Use Cases
- Calling a REST API and getting the response body back as a
String, without manually wiring up anInputStreamReaderover the connection's input stream. - Firing off several independent HTTP calls concurrently and composing their results with
CompletableFuture, instead of blocking one thread per call. - Downloading a file straight to disk by handing the response body a target
Path, without streaming bytes through application code by hand. - Configuring one client (timeouts, redirect policy, proxy) once and reusing it for every request in an application, since
HttpClientinstances are immutable and thread-safe.
Deep Dive
Building a client
javaHttpClient client = HttpClient.newHttpClient(); // default settingsjavaHttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(20))
.build();newHttpClient() is a shortcut for the common case; newBuilder() exposes the configuration knobs — protocol version, redirect policy (ALWAYS, NEVER, or NORMAL, which follows redirects except HTTPS→HTTP downgrades), connect timeout, proxy selector, authenticator. Left unconfigured, the client prefers HTTP/2 and falls back to HTTP/1.1 when the server or proxy doesn't support it. A built HttpClient is immutable and safe to share/reuse across many requests instead of building one per call.
Building a request
javaHttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/42"))
.header("Accept", "application/json")
.GET() // default method is GET; also POST/PUT/DELETE(...) etc.
.build();Like the client, HttpRequest is built once via HttpRequest.newBuilder() and is immutable afterward — headers, method, URI, and body are all fixed at build time.
Sending: synchronous vs. asynchronous
javaHttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.statusCode()); System.out.println(response.body());
send() blocks the calling thread until the response arrives — straightforward for scripts and simple call chains.
javaclient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(HttpResponse::body) .thenAccept(System.out::println);
sendAsync() returns a CompletableFuture<HttpResponse<T>> immediately; the actual request runs without blocking the calling thread, and .thenApply()/.thenAccept()/.thenCombine() chain further processing once the response lands — the same composition style as any other CompletableFuture.
BodyHandlers: choosing the response body's shape
javaHttpResponse.BodyHandlers.ofString(); // response.body() is a String
HttpResponse.BodyHandlers.ofByteArray(); // response.body() is a byte[]
HttpResponse.BodyHandlers.ofFile(Path.of("out.bin")); // streams straight to a file
HttpResponse.BodyHandlers.ofInputStream(); // response.body() is an InputStreamThe BodyHandler passed to send()/sendAsync() determines HttpResponse<T>'s type parameter — pick ofFile() for a large download so bytes stream to disk instead of buffering the whole response in memory, ofString() for a typical JSON API response.
Trade-offs
HttpURLConnectionstill exists and still works —HttpClientdoesn't replace it at the language level, it's simply the API official documentation and most current tutorials point to for new code, thanks to the builder ergonomics and native async support neither of whichHttpURLConnectionever had.- HTTP/2 by default means multiplexed connections, which changes some assumptions carried over from HTTP/1.1-only code — e.g., relying on one connection per request for ordering or rate-limiting reasoning no longer holds the same way.
sendAsync()'sCompletableFuturecomposes well, but exceptions surface differently than withsend(). A synchronoussend()throwsIOExceptiondirectly; an async chain wraps failures in the future itself, and forgetting a.exceptionally()/.handle()stage means a failed request can silently produce no visible error until something calls.get()or.join().javaclient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .exceptionally(ex -> { System.err.println("request failed: " + ex); return null; }) .thenAccept(System.out::println);- Choosing the wrong
BodyHandlerfor the size of the response matters.ofString()/ofByteArray()buffer the entire body in memory before returning — fine for a small JSON payload, a poor choice for a multi-gigabyte download whereofFile()(orofInputStream()for manual streaming) avoids holding the whole thing in memory at once. sendAsync()'s original reason to exist — not tying up one of a limited number of platform threads on I/O wait — mostly disappears on virtual threads (JDK 21+). A virtual thread blocked insend()doesn't pin an OS thread the way a platform thread would; current guidance for virtual-thread-based code favors the simpler synchronoussend()plus spawning many virtual threads for concurrency, over composingCompletableFuturechains to avoid blocking a thread type that's no longer scarce.