| Feature | Detail |
| Protocols | HTTP/1.1 and HTTP/2 (with automatic upgrade) |
| Execution models | Synchronous (send()) and async (sendAsync()) |
| Thread model | Uses a shared ForkJoinPool or custom Executor |
| TLS | Configurable SSLContext and SSLParameters |
| Redirect policy | NEVER, ALWAYS, NORMAL (no HTTP→HTTPS downgrade) |
| Authentication | Pluggable Authenticator for Basic/Digest |
| WebSocket | Built-in newWebSocketBuilder() |
| Connection reuse | Automatic keep-alive and H2 multiplexing |
Build a shared, reusable client instance — do not create one per request.
import java.net.http.*;
import java.time.Duration;
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // prefer HTTP/2
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
// Or use the default singleton (HTTP_2, NEVER redirect, no timeout)
HttpClient defaultClient = HttpClient.newHttpClient();
HttpClient is thread-safe and intended to be shared. Each instance manages its own connection pool. Create one per application, not one per request.
import java.net.URI;
import java.net.http.*;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/1"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("Status : " + response.statusCode());
System.out.println("Body : " + response.body());
System.out.println("Headers: " + response.headers().map());
String json = """
{"name": "Alice", "email": "alice@example.com"}
""";
HttpRequest post = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(post, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode()); // 201 Created
// PUT — update resource
HttpRequest put = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/1"))
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(json))
.build();
// DELETE
HttpRequest delete = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/1"))
.DELETE()
.build();
sendAsync() returns a CompletableFuture — the calling thread is not blocked.
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/data"))
.build();
CompletableFuture<String> future = client
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body);
// Fan-out: send 5 requests concurrently
List<URI> uris = List.of(
URI.create("https://api.example.com/a"),
URI.create("https://api.example.com/b"),
URI.create("https://api.example.com/c")
);
List<CompletableFuture<String>> futures = uris.stream()
.map(uri -> HttpRequest.newBuilder(uri).build())
.map(req -> client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body))
.toList();
// Wait for all to complete
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
futures.forEach(f -> System.out.println(f.join()));
BodyHandlers — how to consume the response body:
| Handler | Returns |
ofString() | String decoded with response charset |
ofByteArray() | Raw byte[] |
ofFile(path) | Saves body to a file, returns Path |
ofInputStream() | InputStream for streaming reads |
ofLines() | Stream<String> — line-by-line streaming |
discarding() | Ignores the body (useful for HEAD / fire-and-forget) |
BodyPublishers — how to supply the request body:
| Publisher | Source |
ofString(s) | String |
ofByteArray(b) | byte[] |
ofFile(path) | File contents |
ofInputStream(supplier) | InputStream (lazily created) |
noBody() | Empty body (GET, DELETE) |
// Stream a large file upload
HttpRequest upload = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/upload"))
.header("Content-Type", "application/octet-stream")
.POST(HttpRequest.BodyPublishers.ofFile(Path.of("/data/large.bin")))
.build();
// Stream response as lines (SSE-like)
HttpResponse<Stream<String>> lines = client.send(
HttpRequest.newBuilder(URI.create("https://stream.example.com/events")).build(),
HttpResponse.BodyHandlers.ofLines());
lines.body().forEach(System.out::println);
// Bearer token — add Authorization header manually
HttpRequest bearerRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/secure"))
.header("Authorization", "Bearer " + token)
.build();
// Basic auth via Authenticator (handles 401 challenges automatically)
HttpClient authClient = HttpClient.newBuilder()
.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication("user", "pass".toCharArray());
}
})
.build();
Java's HttpClient can upgrade an HTTP connection to WebSocket with newWebSocketBuilder().
import java.net.http.WebSocket;
WebSocket ws = client.newWebSocketBuilder()
.buildAsync(URI.create("wss://echo.websocket.org"), new WebSocket.Listener() {
@Override
public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
System.out.println("Received: " + data);
ws.request(1); // request next message
return null;
}
@Override
public CompletionStage<?> onClose(WebSocket ws, int statusCode, String reason) {
System.out.println("Closed: " + statusCode + " " + reason);
return null;
}
@Override
public void onError(WebSocket ws, Throwable error) {
error.printStackTrace();
}
})
.join();
ws.sendText("Hello WebSocket!", true).join(); // true = last fragment
ws.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
Always call ws.request(n) after processing a message. The WebSocket API uses a demand model — it will not deliver more messages until you request them.
When HttpClient.Version.HTTP_2 is set and the server supports it, the client negotiates H2 via ALPN (TLS) or the HTTP Upgrade header. Multiple requests share a single TCP connection without head-of-line blocking.
// Verify which protocol version was used
HttpResponse<String> r = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(r.version()); // HTTP_2 or HTTP_1_1
// Custom executor for async responses
HttpClient h2Client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.executor(Executors.newFixedThreadPool(4))
.build();
HTTP/2 server push was removed in Java 18 (JEP 418). Push promise frames are now rejected with a RST_STREAM. Use standard request-response instead.