Java HttpClient: Modern HTTP Requests in Java 11+

Java 11 introduced java.net.http.HttpClient as a modern replacement for HttpURLConnection. It supports HTTP/1.1 and HTTP/2, synchronous and asynchronous requests, WebSockets, streaming response bodies, and configurable timeouts and redirects. The fluent builder API makes it much cleaner than the old URL-based approach, and CompletableFuture integration enables non-blocking request pipelines.

Contents

Synchronous GET Request

HttpClient.newHttpClient() creates a client with sensible defaults (HTTP/2 preferred, no redirect following). HttpRequest.newBuilder() configures the request with a URI, HTTP method, headers, and an optional per-request timeout. Calling send() blocks the calling thread until the full response arrives. BodyHandlers.ofString() decodes the response body into a String using the charset indicated in the Content-Type header. The HttpClient is thread-safe and should be created once and reused across requests to benefit from connection pooling.

Java
import java.net.http.*;
import java.net.URI;

// Build a shared HttpClient — thread-safe, reuse across requests
HttpClient client = HttpClient.newHttpClient();
// Or with custom config:
HttpClient client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)
    .followRedirects(HttpClient.Redirect.NORMAL)
    .build();

// Build a request
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.github.com/repos/openjdk/jdk"))
    .header("Accept", "application/json")
    .GET()  // default, so optional
    .build();

// Send synchronously — blocks until response arrives
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println("Status:  " + response.statusCode());   // 200
System.out.println("Body:    " + response.body());          // JSON string
System.out.println("Headers: " + response.headers().map()); // Map of headers

// Check status and handle errors
if (response.statusCode() == 200) {
    String json = response.body();
    // parse JSON with Jackson, Gson, etc.
} else {
    System.err.println("Error: " + response.statusCode());
}

POST with JSON Body

BodyPublishers.ofString() sends a string as the request body, encoded as UTF-8 by default. You must explicitly set the Content-Type header to application/json: the client does not infer it from the body. After sending, check HttpResponse.statusCode() for a 2xx response before processing the body. For deserializing the JSON response into a Java object, pass the body string to a JSON library such as Jackson or Gson; there is no built-in JSON binding in HttpClient.

Java
import java.net.http.*;
import java.net.URI;

HttpClient client = HttpClient.newHttpClient();

// POST with a JSON string body
String jsonBody = """
    {
        "title": "New Issue",
        "body":  "Description of the issue",
        "labels": ["bug"]
    }
    """;

HttpRequest postRequest = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/issues"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
    .build();

HttpResponse<String> response = client.send(postRequest, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode()); // 201 Created
System.out.println(response.body());

// PUT request
HttpRequest putRequest = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/issues/42"))
    .header("Content-Type", "application/json")
    .PUT(HttpRequest.BodyPublishers.ofString("{\"state\": \"closed\"}"))
    .build();

// DELETE request
HttpRequest deleteRequest = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/issues/42"))
    .DELETE()
    .build();

// Form-encoded POST (application/x-www-form-urlencoded)
String formBody = "username=alice&password=secret";
HttpRequest formRequest = HttpRequest.newBuilder()
    .uri(URI.create("https://auth.example.com/login"))
    .header("Content-Type", "application/x-www-form-urlencoded")
    .POST(HttpRequest.BodyPublishers.ofString(formBody))
    .build();

Async Requests with CompletableFuture

sendAsync() submits the request and returns a CompletableFuture<HttpResponse<T>> immediately without blocking the calling thread. Chain thenApply() to transform the response and thenAccept() to consume it. To fire multiple requests in parallel, create a list of futures and use CompletableFuture.allOf() to wait for all of them; call join() on each future to retrieve the result once allOf completes. This pattern allows N HTTP calls to execute concurrently without blocking N threads.

Java
import java.net.http.*;
import java.util.concurrent.CompletableFuture;
import java.util.List;

HttpClient client = HttpClient.newHttpClient();

// sendAsync — returns CompletableFuture, does not block
CompletableFuture<HttpResponse<String>> future =
    client.sendAsync(
        HttpRequest.newBuilder()
            .uri(URI.create("https://api.example.com/data"))
            .GET().build(),
        HttpResponse.BodyHandlers.ofString()
    );

// Process result when done (non-blocking)
future
    .thenApply(HttpResponse::body)
    .thenAccept(body -> System.out.println("Got: " + body))
    .exceptionally(ex -> { System.err.println("Error: " + ex.getMessage()); return null; });

// Fire multiple requests in parallel
List<String> urls = List.of(
    "https://api.example.com/users/1",
    "https://api.example.com/users/2",
    "https://api.example.com/users/3"
);

List<CompletableFuture<String>> futures = urls.stream()
    .map(url -> client.sendAsync(
            HttpRequest.newBuilder().uri(URI.create(url)).GET().build(),
            HttpResponse.BodyHandlers.ofString())
        .thenApply(HttpResponse::body))
    .toList();

// Wait for all to complete
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
    .thenRun(() -> futures.forEach(f -> System.out.println(f.join())))
    .join(); // block until all done
HttpClient.newHttpClient() should be created once and reused: it manages a connection pool internally. Creating a new HttpClient per request defeats connection reuse and hurts performance.

Timeouts, Redirects, and Configuration

HttpClient.Builder centralises client-level settings that apply to all requests: connectTimeout limits how long to wait for a TCP connection; followRedirects accepts NEVER, NORMAL (follow except HTTPS-to-HTTP downgrades), or ALWAYS; version sets the preferred HTTP version. A per-request timeout can be set via HttpRequest.timeout() and applies to the total time waiting for a response. Connection timeouts throw ConnectException; request timeouts throw HttpTimeoutException: catch them separately for meaningful error handling.

Java
import java.time.Duration;
import java.net.Authenticator;
import java.net.PasswordAuthentication;

// Comprehensive client configuration
HttpClient client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)          // prefer HTTP/2, fallback to HTTP/1.1
    .connectTimeout(Duration.ofSeconds(5))       // connection timeout
    .followRedirects(HttpClient.Redirect.NORMAL) // follow non-HTTPS-to-HTTP redirects
    .authenticator(new Authenticator() {         // Basic auth
        @Override
        protected PasswordAuthentication getPasswordAuthentication() {
            return new PasswordAuthentication("user", "pass".toCharArray());
        }
    })
    .build();

// Per-request timeout (overrides client-level timeout)
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/slow-endpoint"))
    .timeout(Duration.ofSeconds(10))  // request (read) timeout
    .header("User-Agent", "MyApp/1.0")
    .GET()
    .build();

// HttpClient.Redirect options:
// NEVER   — never follow redirects
// ALWAYS  — always follow (including HTTPS → HTTP, which leaks headers)
// NORMAL  — follow except HTTPS → HTTP downgrade (recommended)

try {
    HttpResponse<String> resp = client.send(request, HttpResponse.BodyHandlers.ofString());
    // response.uri() — final URI after redirects
    System.out.println("Final URL: " + resp.uri());
} catch (java.net.http.HttpTimeoutException e) {
    System.err.println("Request timed out");
} catch (java.net.ConnectException e) {
    System.err.println("Cannot connect: " + e.getMessage());
}

Body Handlers and Publishers

BodyHandlers controls how the response body is consumed: ofString() loads it entirely into memory as a String; ofBytes() loads it as a byte[]; ofFile(path) streams it directly to disk; ofInputStream() gives raw streaming access; ofLines() returns a lazy Stream<String>; discarding() drops the body entirely when only the status code or headers matter. BodyPublishers mirrors this for request bodies: ofString(), ofByteArray(), ofFile(), and noBody() for requests with no body such as GET or DELETE.

Java
// BodyHandlers — control how response body is consumed
HttpResponse.BodyHandler<?> handler;

// ofString()          — full body as String (all in memory)
// ofInputStream()     — raw InputStream for streaming large responses
// ofByteArray()       — body as byte[]
// ofFile(Path)        — stream body to a file
// ofLines()           — Stream<String> of lines (lazy)
// discarding()        — discard body (only need status/headers)

// Stream large response to a file
HttpResponse<Path> fileResp = client.send(
    HttpRequest.newBuilder().uri(URI.create("https://example.com/large.zip")).GET().build(),
    HttpResponse.BodyHandlers.ofFile(Path.of("downloaded.zip"))
);
System.out.println("Saved to: " + fileResp.body());

// Stream lines lazily
HttpResponse<Stream<String>> linesResp = client.send(
    HttpRequest.newBuilder().uri(URI.create("https://example.com/log")).GET().build(),
    HttpResponse.BodyHandlers.ofLines()
);
try (Stream<String> lines = linesResp.body()) {
    lines.filter(l -> l.contains("ERROR")).forEach(System.out::println);
}

// BodyPublishers — control how request body is sent
HttpRequest.BodyPublisher pub;
HttpRequest.BodyPublishers.ofString("hello");           // from String
HttpRequest.BodyPublishers.ofByteArray(bytes);          // from byte[]
HttpRequest.BodyPublishers.ofFile(Path.of("data.json")); // from file
HttpRequest.BodyPublishers.noBody();                    // for GET/DELETE