Contents
- Synchronous GET Request
- POST with JSON Body
- Async Requests with CompletableFuture
- Timeouts, Redirects, and Configuration
- Body Handlers and Publishers
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.
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());
}
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.
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();
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.
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.
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.
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());
}
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.
// 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