Skip to main content

HTTP Clients

Since Java 11, the JDK ships with a modern, built-in HTTP client (java.net.http.HttpClient) that supports HTTP/1.1, HTTP/2, synchronous and asynchronous requests, and WebSocket. For most use cases, you no longer need a third-party library.

java.net.http.HttpClient (Java 11+)

Basic GET request

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/1"))
.header("Accept", "application/json")
.GET() // default, can be omitted
.build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println(response.statusCode()); // 200
System.out.println(response.body()); // {"name":"Alice",...}
System.out.println(response.headers().map()); // all headers

POST with JSON body

String json = """
{
"name": "Alice",
"email": "alice@example.com"
}
""";

HttpRequest request = 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(request, HttpResponse.BodyHandlers.ofString());

Other HTTP methods

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

// PATCH (use method())
HttpRequest patch = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/1"))
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(json))
.build();

Client configuration

Timeouts

HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/slow"))
.timeout(Duration.ofSeconds(10)) // per-request timeout
.build();

try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (HttpTimeoutException e) {
System.err.println("Request timed out: " + e.getMessage());
}

HTTP/2 and redirects

HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // prefer HTTP/2
.followRedirects(HttpClient.Redirect.NORMAL) // follow 3xx redirects
.build();

Authentication

HttpClient client = HttpClient.newBuilder()
.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication("user", "pass".toCharArray());
}
})
.build();

Custom headers on every request

The built-in client does not have a global interceptor. Create a helper:

class ApiClient {
private final HttpClient client = HttpClient.newHttpClient();
private final String baseUrl;
private final String apiKey;

ApiClient(String baseUrl, String apiKey) {
this.baseUrl = baseUrl;
this.apiKey = apiKey;
}

HttpRequest.Builder requestBuilder(String path) {
return HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path))
.header("Authorization", "Bearer " + apiKey)
.header("Accept", "application/json");
}

HttpResponse<String> get(String path) throws IOException, InterruptedException {
return client.send(
requestBuilder(path).GET().build(),
HttpResponse.BodyHandlers.ofString()
);
}

HttpResponse<String> post(String path, String body)
throws IOException, InterruptedException {
return client.send(
requestBuilder(path)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build(),
HttpResponse.BodyHandlers.ofString()
);
}
}

Async requests

The sendAsync method returns a CompletableFuture:

HttpClient client = HttpClient.newHttpClient();

CompletableFuture<HttpResponse<String>> future = client.sendAsync(
HttpRequest.newBuilder(URI.create("https://api.example.com/users")).build(),
HttpResponse.BodyHandlers.ofString()
);

// Non-blocking chaining
future
.thenApply(HttpResponse::body)
.thenApply(body -> parseJson(body))
.thenAccept(user -> System.out.println("Got: " + user.name()))
.exceptionally(ex -> {
System.err.println("Failed: " + ex.getMessage());
return null;
});

Parallel requests

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.create(url)).build(),
HttpResponse.BodyHandlers.ofString()
).thenApply(HttpResponse::body))
.toList();

// Wait for all
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();

List<String> bodies = futures.stream()
.map(CompletableFuture::join)
.toList();

Body handlers and publishers

Response body handlers

// String
HttpResponse<String> strResp = client.send(req, HttpResponse.BodyHandlers.ofString());

// byte[]
HttpResponse<byte[]> byteResp = client.send(req, HttpResponse.BodyHandlers.ofByteArray());

// File download
HttpResponse<Path> fileResp = client.send(req,
HttpResponse.BodyHandlers.ofFile(Path.of("download.zip")));

// Stream of lines
HttpResponse<Stream<String>> lineResp = client.send(req,
HttpResponse.BodyHandlers.ofLines());

// Discard body
HttpResponse<Void> discardResp = client.send(req, HttpResponse.BodyHandlers.discarding());

Request body publishers

// String
HttpRequest.BodyPublishers.ofString("{\"key\":\"value\"}")

// File
HttpRequest.BodyPublishers.ofFile(Path.of("data.json"))

// byte[]
HttpRequest.BodyPublishers.ofByteArray(bytes)

// No body
HttpRequest.BodyPublishers.noBody()

// Form data
String formBody = "username=alice&password=secret";
HttpRequest.BodyPublishers.ofString(formBody)
// + header("Content-Type", "application/x-www-form-urlencoded")

JSON with Jackson

Combine the HTTP client with Jackson for typed JSON handling:

record User(String name, String email, int age) {}

ObjectMapper mapper = new ObjectMapper();

// Deserialize response
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
User user = mapper.readValue(response.body(), User.class);

// Serialize request body
User newUser = new User("Alice", "alice@example.com", 30);
String json = mapper.writeValueAsString(newUser);

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();

Retry pattern

<T> HttpResponse<T> sendWithRetry(
HttpClient client,
HttpRequest request,
HttpResponse.BodyHandler<T> handler,
int maxRetries) throws IOException, InterruptedException {

IOException lastException = null;

for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
HttpResponse<T> response = client.send(request, handler);
if (response.statusCode() < 500) {
return response; // success or client error (no retry)
}
// Server error -- retry
lastException = new IOException("Server error: " + response.statusCode());
} catch (IOException e) {
lastException = e;
}

if (attempt < maxRetries) {
long delay = (long) Math.pow(2, attempt) * 1000; // exponential backoff
Thread.sleep(delay);
}
}
throw lastException;
}

// Usage
HttpResponse<String> response = sendWithRetry(client, request,
HttpResponse.BodyHandlers.ofString(), 3);

Library comparison

Featurejava.net.httpOkHttpApache HttpClient 5
JDK version11+ (built-in)External dependencyExternal dependency
HTTP/2YesYesYes
AsyncCompletableFutureCall.enqueue(Callback)SimpleHttpAsyncClient
InterceptorsNo (manual)Yes (built-in chain)Yes (request/response interceptors)
Connection poolingYes (automatic)Yes (configurable)Yes (configurable)
WebSocketYesYesNo
Multipart uploadManualMultipartBodyMultipartEntityBuilder
Cookie handlingCookieHandlerCookieJarCookieStore
Bundle size0 KB (JDK)~400 KB~800 KB

When to use which

ScenarioRecommendation
Simple API calls, no extra dependenciesjava.net.http
Need interceptors, logging, retry middlewareOkHttp
Enterprise, fine-grained connection managementApache HttpClient 5
Android developmentOkHttp (standard in Android ecosystem)

Common pitfalls

PitfallProblemFix
No timeout configuredRequest hangs forever on slow serversSet both connectTimeout and per-request timeout
Ignoring status codesTreating 4xx/5xx as successCheck response.statusCode() before processing body
Not closing InputStream body handlersResource leakUse ofString() or ensure streams are closed
Creating a new HttpClient per requestMisses connection pooling and HTTP/2 multiplexingReuse a single HttpClient instance
Blocking on CompletableFuture in async codeDefeats the purpose of asyncChain with thenApply / thenAccept
Hardcoded base URLsDifficult to test, environment-specificInject base URL via configuration

See also