Java HttpClient Deep Dive: HTTP/2, Async Requests & WebSocket Upgrade

Java 11 introduced java.net.http.HttpClient: a modern, fluent HTTP client with first-class support for HTTP/1.1, HTTP/2, async CompletableFuture-based requests, and WebSocket. This article covers everything from basic GET requests to streaming bodies, authentication, and upgrading to WebSocket.

Share

HttpClient Overview & Key Features

java.net.http.HttpClient, introduced in Java 11, is the standard HTTP client built into the JDK. Before it existed, developers had to use the aging HttpURLConnection API or pull in a third-party library such as Apache HttpClient or OkHttp just to send a simple HTTP request. The new client is fluent (built with a builder pattern), supports both HTTP/1.1 and HTTP/2 transparently, and offers two execution models: synchronous (send() blocks until the response arrives) and asynchronous (sendAsync() returns a CompletableFuture so the calling thread is never blocked). WebSocket upgrade is also built in through the same API.

FeatureDetail
ProtocolsHTTP/1.1 and HTTP/2 (with automatic upgrade)
Execution modelsSynchronous (send()) and async (sendAsync())
Thread modelUses a shared ForkJoinPool or custom Executor
TLSConfigurable SSLContext and SSLParameters
Redirect policyNEVER, ALWAYS, NORMAL (no HTTP→HTTPS downgrade)
AuthenticationPluggable Authenticator for Basic/Digest
WebSocketBuilt-in newWebSocketBuilder()
Connection reuseAutomatic keep-alive and H2 multiplexing

Creating an HttpClient

Build a shared, reusable client instance: do not create one per request.

Java
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.

Synchronous GET Request

Java
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());

POST & PUT with a Request Body

Java
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();

Asynchronous Requests with sendAsync()

sendAsync() returns a CompletableFuture: the calling thread is not blocked.

Java
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 & BodyPublishers Reference

BodyHandlers: how to consume the response body:

HandlerReturns
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:

PublisherSource
ofString(s)String
ofByteArray(b)byte[]
ofFile(path)File contents
ofInputStream(supplier)InputStream (lazily created)
noBody()Empty body (GET, DELETE)
Java
// 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);

Authentication: Basic, Bearer & Authenticator

Java
// 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();

WebSocket Upgrade

Java's HttpClient can upgrade an HTTP connection to WebSocket with newWebSocketBuilder().

Java
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.

HTTP/2: Multiplexing & Server Push

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.

Java
// 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.