For nearly two decades, Java developers who needed to make HTTP requests were stuck with HttpURLConnection — a class from Java 1.1 that was clunky, verbose, and painful to use. Making a simple GET request required 20+ lines of boilerplate code with manual stream handling, and doing anything asynchronous meant managing your own threads. Third-party libraries like Apache HttpClient, OkHttp, and Unirest filled the gap, but it was always awkward that the language itself lacked a modern HTTP client.
Java 11 changed that. The java.net.http package introduced a brand new HttpClient API that is modern, fluent, and built for today’s web. It was first incubated in Java 9, refined in Java 10, and finalized as a standard API in Java 11 (JEP 321).
What was wrong with HttpURLConnection?
getInputStream() throws on 4xx/5xx but getErrorStream() does notWhat Java 11 HttpClient provides:
Think of the new HttpClient like upgrading from a rotary phone to a smartphone. Both make calls, but one is built for the modern world. The old HttpURLConnection is the rotary phone — it works, but every interaction is painful. The new HttpClient is the smartphone — intuitive, powerful, and designed for how we actually use HTTP today.
The three core classes:
| Class | Purpose | Analogy |
|---|---|---|
HttpClient |
Sends requests, manages connections, holds configuration | The web browser itself |
HttpRequest |
Represents an HTTP request (URL, method, headers, body) | Typing a URL and clicking Send |
HttpResponse |
Represents the server’s response (status, headers, body) | The web page that loads |
All three classes are immutable and thread-safe. You build them using the builder pattern, which means no setters, no mutable state, and no surprises in concurrent code.
// The old way: HttpURLConnection (Java 1.1 - painful)
URL url = new URL("https://api.example.com/users/1");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
int status = conn.getResponseCode();
BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream()));
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
reader.close();
conn.disconnect();
String body = response.toString();
Now compare that to the Java 11 HttpClient:
// The new way: Java 11 HttpClient (clean and modern)
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/1"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
String body = response.body();
That is 5 lines of meaningful code versus 15+. No manual stream handling, no try-catch soup, no forgetting to close connections. And the async version is just as clean — replace send() with sendAsync() and you get a CompletableFuture.
The HttpClient is your connection manager. You create one instance and reuse it for all your HTTP calls — it manages connection pooling, thread pools, and protocol negotiation internally. Creating a new HttpClient for every request is like opening a new browser window for every web page — wasteful and slow.
The simplest way to create a client uses the static factory method:
// Simplest creation -- uses all defaults HttpClient client = HttpClient.newHttpClient(); // Equivalent to: // HttpClient client = HttpClient.newBuilder().build();
The defaults are sensible for most cases: HTTP/2 preferred (with HTTP/1.1 fallback), no redirects followed, no proxy, system default SSL context, and a default executor (thread pool).
For production applications, you will want to configure the client explicitly:
import java.net.http.HttpClient;
import java.net.ProxySelector;
import java.time.Duration;
import java.util.concurrent.Executors;
import javax.net.ssl.SSLContext;
public class HttpClientCreation {
public static void main(String[] args) throws Exception {
// Production-ready client with full configuration
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // Prefer HTTP/2
.followRedirects(HttpClient.Redirect.NORMAL) // Follow redirects (not HTTPS->HTTP)
.connectTimeout(Duration.ofSeconds(10)) // Connection timeout
.proxy(ProxySelector.getDefault()) // Use system proxy
.executor(Executors.newFixedThreadPool(5)) // Custom thread pool for async
.build();
System.out.println("Client version: " + client.version());
// Output: Client version: HTTP_2
}
}
Builder options explained:
| Method | Options | Default | Description |
|---|---|---|---|
version() |
HTTP_2, HTTP_1_1 |
HTTP_2 |
Preferred HTTP version. HTTP/2 falls back to 1.1 if server does not support it |
followRedirects() |
NEVER, ALWAYS, NORMAL |
NEVER |
NORMAL follows redirects except HTTPS to HTTP (security) |
connectTimeout() |
Any Duration |
No timeout | Max time to establish TCP connection |
proxy() |
ProxySelector |
No proxy | Proxy configuration for all requests |
executor() |
Any Executor |
Default pool | Thread pool used for async operations |
authenticator() |
Authenticator |
None | Basic/Digest HTTP authentication |
sslContext() |
SSLContext |
System default | Custom SSL/TLS configuration |
cookieHandler() |
CookieHandler |
None | Cookie management (e.g., CookieManager) |
priority() |
1 to 256 | Not set | HTTP/2 stream priority |
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.http.HttpClient;
// Client that stores and sends cookies automatically
CookieManager cookieManager = new CookieManager();
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);
HttpClient client = HttpClient.newBuilder()
.cookieHandler(cookieManager)
.build();
// Now the client will store cookies from responses
// and send them with subsequent requests -- useful for session management
import java.net.Authenticator;
import java.net.PasswordAuthentication;
import java.net.http.HttpClient;
// Client with built-in HTTP Basic/Digest authentication
HttpClient client = HttpClient.newBuilder()
.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(
"username",
"password".toCharArray()
);
}
})
.build();
// The client handles 401 challenges automatically
Key rule: Create one HttpClient instance and reuse it. The client manages an internal connection pool. Creating a new client per request wastes resources and loses connection reuse benefits.
GET requests are the most common HTTP operation — fetching data from an API, downloading a page, checking if a resource exists. With Java 11’s HttpClient, a GET request is straightforward: build the request, send it, read the response.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class BasicGetRequest {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.GET() // GET is the default, so this line is optional
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Body: " + response.body());
// Output:
// Status: 200
// Body: {
// "userId": 1,
// "id": 1,
// "title": "sunt aut facere repellat provident occaecati...",
// "body": "quia et suscipit\nsuscipit recusandae..."
// }
}
}
Let us break down the flow:
HttpRequest.newBuilder() starts a builder. Set the URI, method, headers, and timeout.client.send() is synchronous (blocks until response arrives). The second parameter is a BodyHandler that tells the client how to process the response body.response.statusCode() gives the HTTP status code. response.body() gives the body in whatever format the BodyHandler specified (here, String).import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class GetWithHeaders {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
.header("Accept", "application/json")
.header("User-Agent", "Java11-HttpClient-Tutorial")
.timeout(Duration.ofSeconds(30)) // Request-level timeout
.GET()
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Content-Type: " +
response.headers().firstValue("content-type").orElse("unknown"));
System.out.println("Body length: " + response.body().length() + " chars");
// Output:
// Status: 200
// Content-Type: application/json; charset=utf-8
// Body length: 27520 chars
}
}
Important distinction: connectTimeout on the client controls how long to wait for a TCP connection. timeout on the request controls how long to wait for the entire request-response cycle. In production, set both.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
public class GetToFile {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
.build();
// Save response body directly to a file
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofFile(Path.of("posts.json")));
System.out.println("Status: " + response.statusCode());
System.out.println("Saved to: " + response.body());
// Output:
// Status: 200
// Saved to: posts.json
}
}
POST requests send data to a server — creating resources, submitting forms, uploading files. The key difference from GET is that POST requests have a request body. Java 11 provides BodyPublishers to create request bodies from various sources.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class PostJsonString {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
// JSON body as a plain string
String json = """
{
"title": "New Post",
"body": "This is the content of my new post.",
"userId": 1
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Response: " + response.body());
// Output:
// Status: 201
// Response: {
// "title": "New Post",
// "body": "This is the content of my new post.",
// "userId": 1,
// "id": 101
// }
}
}
Note: The text block syntax (""") shown above requires Java 13+. For Java 11 strictly, concatenate the JSON string or use a single-line string. We use text blocks here for readability since most Java 11+ projects eventually upgrade.
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
public class PostFormData {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
// URL-encoded form data
String formData = "title=" + URLEncoder.encode("My Post", StandardCharsets.UTF_8)
+ "&body=" + URLEncoder.encode("Post content here", StandardCharsets.UTF_8)
+ "&userId=1";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(formData))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Response: " + response.body());
// Output:
// Status: 201
// Response: { "title": "My Post", "body": "Post content here", "userId": "1", "id": 101 }
}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
public class PostFromFile {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
// Read request body from a file
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofFile(Path.of("data.json")))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
public class PostByteArray {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
byte[] data = "{\"title\":\"Byte Post\",\"userId\":1}"
.getBytes(StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(data))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Response: " + response.body());
}
}
BodyPublishers summary:
| BodyPublisher | Source | Use Case |
|---|---|---|
ofString(String) |
String in memory | JSON, XML, form data, text |
ofString(String, Charset) |
String with specific encoding | Non-UTF-8 data |
ofFile(Path) |
File on disk | Uploading files, large payloads |
ofByteArray(byte[]) |
Byte array in memory | Binary data, pre-serialized content |
ofByteArray(byte[], int, int) |
Byte array slice | Partial byte array |
ofInputStream(Supplier) |
InputStream supplier | Streaming data, lazy reading |
noBody() |
Empty body | DELETE, HEAD requests |
Every HTTP call returns an HttpResponse object. This object contains everything the server sent back — the status code, headers, and body. The type of the body depends on which BodyHandler you passed to send() or sendAsync().
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
public class ResponseDetails {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
// Status code
int status = response.statusCode();
System.out.println("Status: " + status); // 200
// Response body
String body = response.body();
System.out.println("Body length: " + body.length());
// The URI that was requested (useful after redirects)
System.out.println("URI: " + response.uri());
// HTTP version used
System.out.println("Version: " + response.version());
// The original request
HttpRequest originalRequest = response.request();
// All response headers
Map> headers = response.headers().map();
headers.forEach((name, values) ->
System.out.println(name + ": " + values));
// Output includes:
// content-type: [application/json; charset=utf-8]
// cache-control: [max-age=43200]
// ...
// Get a specific header
String contentType = response.headers()
.firstValue("content-type")
.orElse("unknown");
System.out.println("Content-Type: " + contentType);
// Get all values for a header (some headers appear multiple times)
List cacheHeaders = response.headers()
.allValues("cache-control");
System.out.println("Cache-Control: " + cacheHeaders);
// Previous response (if redirected)
response.previousResponse().ifPresent(prev ->
System.out.println("Redirected from: " + prev.uri()));
}
}
The BodyHandler determines how the response body is processed. Think of it as telling the client “I want the response as a ___”:
| BodyHandler | Response Type | Use Case |
|---|---|---|
ofString() |
HttpResponse<String> |
JSON, HTML, text responses |
ofFile(Path) |
HttpResponse<Path> |
Download files directly to disk |
ofByteArray() |
HttpResponse<byte[]> |
Binary data, images |
ofInputStream() |
HttpResponse<InputStream> |
Large responses, streaming |
ofLines() |
HttpResponse<Stream<String>> |
Line-by-line processing |
discarding() |
HttpResponse<Void> |
Only care about status/headers |
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
import java.util.stream.Stream;
public class BodyHandlerExamples {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
String url = "https://jsonplaceholder.typicode.com/posts";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.build();
// 1. As String (most common)
HttpResponse stringResp = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("String body length: " + stringResp.body().length());
// 2. As byte array
HttpResponse bytesResp = client.send(request,
HttpResponse.BodyHandlers.ofByteArray());
System.out.println("Byte array length: " + bytesResp.body().length);
// 3. As file download
HttpResponse fileResp = client.send(request,
HttpResponse.BodyHandlers.ofFile(Path.of("output.json")));
System.out.println("Saved to: " + fileResp.body());
// 4. As InputStream (for large responses)
HttpResponse streamResp = client.send(request,
HttpResponse.BodyHandlers.ofInputStream());
try (InputStream is = streamResp.body()) {
byte[] firstBytes = is.readNBytes(100);
System.out.println("First 100 bytes: " + new String(firstBytes));
}
// 5. As Stream of lines
HttpResponse> linesResp = client.send(request,
HttpResponse.BodyHandlers.ofLines());
linesResp.body()
.limit(5)
.forEach(line -> System.out.println("Line: " + line));
// 6. Discarding body (only care about status)
HttpResponse discardResp = client.send(request,
HttpResponse.BodyHandlers.discarding());
System.out.println("Status only: " + discardResp.statusCode());
}
}
Synchronous requests block the calling thread until the response arrives. That is fine for simple scripts, but in a server application handling thousands of requests, blocking threads is expensive. The sendAsync() method returns a CompletableFuture, allowing you to fire off requests without blocking and process results when they arrive.
Think of it like ordering food at a restaurant. Synchronous is like standing at the counter waiting for your order. Asynchronous is like taking a buzzer — you go sit down, do other things, and the buzzer notifies you when your food is ready.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CompletableFuture;
public class AsyncBasic {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.build();
// sendAsync returns immediately with a CompletableFuture
CompletableFuture> future = client.sendAsync(
request, HttpResponse.BodyHandlers.ofString());
System.out.println("Request sent, doing other work...");
// Process the response when it arrives
future.thenApply(HttpResponse::body)
.thenAccept(body -> System.out.println("Got response: " +
body.substring(0, 50) + "..."))
.join(); // Wait for completion (in main thread)
// Output:
// Request sent, doing other work...
// Got response: {
// "userId": 1,
// "id": 1,
// "title": "su...
}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CompletableFuture;
public class AsyncChaining {
public static void main(String[] args) {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.build();
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(response -> {
System.out.println("Status: " + response.statusCode());
return response.body();
})
.thenApply(body -> {
// Parse or transform the body
return body.length();
})
.thenAccept(length -> {
System.out.println("Response body was " + length + " characters");
})
.exceptionally(ex -> {
System.err.println("Request failed: " + ex.getMessage());
return null;
})
.join();
// Output:
// Status: 200
// Response body was 292 characters
}
}
The real power of async shines when you need to make multiple requests in parallel. Instead of sending them one-by-one and waiting 5 seconds total, you send all of them simultaneously and wait for the slowest one:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
public class ParallelRequests {
public static void main(String[] args) {
HttpClient client = HttpClient.newHttpClient();
// 5 different endpoints to fetch in parallel
List urls = List.of(
"https://jsonplaceholder.typicode.com/posts/1",
"https://jsonplaceholder.typicode.com/posts/2",
"https://jsonplaceholder.typicode.com/posts/3",
"https://jsonplaceholder.typicode.com/users/1",
"https://jsonplaceholder.typicode.com/comments/1"
);
Instant start = Instant.now();
// Launch all requests in parallel
List> futures = urls.stream()
.map(url -> HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(10))
.build())
.map(request -> client.sendAsync(request,
HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.exceptionally(ex -> "Error: " + ex.getMessage()))
.collect(Collectors.toList());
// Wait for ALL requests to complete
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.join();
// Collect results
List results = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
Duration elapsed = Duration.between(start, Instant.now());
System.out.println("Fetched " + results.size() + " responses");
System.out.println("Total time: " + elapsed.toMillis() + " ms");
results.forEach(r ->
System.out.println("Response preview: " +
r.substring(0, Math.min(60, r.length())) + "..."));
// Output (times vary):
// Fetched 5 responses
// Total time: 312 ms <-- All 5 run in parallel, not 5x sequential time
// Response preview: {
// "userId": 1,
// "id": 1,
// "title": "sunt aut...
}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
public class AsyncErrorHandling {
public static void main(String[] args) {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
// Request to a non-existent host
HttpRequest badRequest = HttpRequest.newBuilder()
.uri(URI.create("https://this-does-not-exist-12345.com/api"))
.timeout(Duration.ofSeconds(3))
.build();
client.sendAsync(badRequest, HttpResponse.BodyHandlers.ofString())
.thenApply(response -> {
if (response.statusCode() >= 400) {
throw new RuntimeException(
"HTTP error: " + response.statusCode());
}
return response.body();
})
.thenAccept(body -> System.out.println("Success: " + body))
.exceptionally(ex -> {
// This catches both network errors and our thrown exceptions
System.err.println("Failed: " + ex.getMessage());
// Return a fallback value or handle the error
return null;
})
.join();
// Output:
// Failed: java.net.ConnectException: ...
}
}
Besides GET and POST, REST APIs commonly use PUT (update a resource), DELETE (remove a resource), and PATCH (partial update). Java 11's HttpClient supports all of these.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class PutRequest {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
String updatedJson = "{\"id\":1,\"title\":\"Updated Title\","
+ "\"body\":\"Updated content\",\"userId\":1}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(updatedJson))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Response: " + response.body());
// Output:
// Status: 200
// Response: {"id":1,"title":"Updated Title","body":"Updated content","userId":1}
}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class DeleteRequest {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.DELETE()
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Response: " + response.body());
// Output:
// Status: 200
// Response: {}
}
}
The HttpRequest builder does not have a dedicated PATCH() method, but you can use the generic method() to set any HTTP method:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class PatchRequest {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
// PATCH: partial update -- only send the fields you want to change
String patchJson = "{\"title\":\"Patched Title Only\"}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchJson))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Response: " + response.body());
// Output:
// Status: 200
// Response: {"userId":1,"id":1,"title":"Patched Title Only",
// "body":"original body unchanged..."}
}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class HeadRequest {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
// HEAD: get headers only, no body
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.method("HEAD", HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.discarding());
System.out.println("Status: " + response.statusCode());
response.headers().firstValue("content-type")
.ifPresent(ct -> System.out.println("Content-Type: " + ct));
response.headers().firstValue("content-length")
.ifPresent(cl -> System.out.println("Content-Length: " + cl));
// Output:
// Status: 200
// Content-Type: application/json; charset=utf-8
}
}
HTTP methods summary:
| Method | Builder Method | Body Required | Typical Use |
|---|---|---|---|
| GET | .GET() |
No | Fetch resource |
| POST | .POST(bodyPublisher) |
Yes | Create resource |
| PUT | .PUT(bodyPublisher) |
Yes | Replace resource |
| DELETE | .DELETE() |
No | Delete resource |
| PATCH | .method("PATCH", bodyPublisher) |
Yes | Partial update |
| HEAD | .method("HEAD", noBody()) |
No | Headers only check |
Real-world API calls almost always involve query parameters (for filtering, pagination, searching) and custom headers (for authentication, content negotiation, versioning). The HttpClient API does not have a dedicated query parameter builder, so you construct the URL yourself. Headers, however, have clean builder support.
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.stream.Collectors;
public class QueryParameters {
// Utility method to build query strings from a map
public static String buildQueryString(Map params) {
return params.entrySet().stream()
.map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)
+ "="
+ URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
}
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
// Approach 1: Manual URL construction
String baseUrl = "https://jsonplaceholder.typicode.com/posts";
HttpRequest request1 = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "?userId=1&_limit=5"))
.build();
// Approach 2: Using a utility method (safer for special characters)
Map params = Map.of(
"userId", "1",
"_limit", "5",
"q", "search term with spaces"
);
String queryString = buildQueryString(params);
HttpRequest request2 = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "?" + queryString))
.build();
HttpResponse response = client.send(request1,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("URI: " + response.uri());
// Output:
// Status: 200
// URI: https://jsonplaceholder.typicode.com/posts?userId=1&_limit=5
}
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
public class CustomHeaders {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
// Multiple headers on a request
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
.header("Accept", "application/json")
.header("Accept-Language", "en-US")
.header("X-Custom-Header", "my-value")
.header("X-Request-ID", "req-12345")
.build();
// Bearer token authentication
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...";
HttpRequest authRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/protected"))
.header("Authorization", "Bearer " + token)
.header("Accept", "application/json")
.build();
// Basic authentication via header
String credentials = Base64.getEncoder()
.encodeToString("username:password".getBytes());
HttpRequest basicAuthRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/basic-auth"))
.header("Authorization", "Basic " + credentials)
.build();
// API key in header
HttpRequest apiKeyRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/data"))
.header("X-API-Key", "your-api-key-here")
.header("Accept", "application/json")
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
}
}
header() vs headers(): The header(name, value) method adds a single header. If you call it twice with the same header name, both values are included (multi-valued header). The headers(String...) method accepts alternating name-value pairs for bulk setting:
// Adding multiple headers at once with headers()
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/data"))
.headers(
"Accept", "application/json",
"Content-Type", "application/json",
"Authorization", "Bearer my-token",
"X-Request-ID", "12345"
)
.build();
// Same result as calling .header() four times
Most modern APIs communicate using JSON. Java does not include a JSON parser in the standard library, so you have two choices: parse JSON manually (for simple cases) or use a library like Gson or Jackson (for production code). Let us look at both approaches.
For simple JSON structures, you can extract values using basic string operations. This is not recommended for production but is useful for scripts and learning:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class ManualJsonParsing {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/users/1"))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
String json = response.body();
// Very basic extraction -- works for simple flat JSON
// DO NOT use this for production code
String name = extractValue(json, "name");
String email = extractValue(json, "email");
String phone = extractValue(json, "phone");
System.out.println("Name: " + name);
System.out.println("Email: " + email);
System.out.println("Phone: " + phone);
// Output:
// Name: Leanne Graham
// Email: Sincere@april.biz
// Phone: 1-770-736-8031 x56442
}
// Naive JSON value extractor -- only for simple cases
static String extractValue(String json, String key) {
String searchKey = "\"" + key + "\"";
int keyIndex = json.indexOf(searchKey);
if (keyIndex == -1) return null;
int colonIndex = json.indexOf(":", keyIndex);
int valueStart = json.indexOf("\"", colonIndex) + 1;
int valueEnd = json.indexOf("\"", valueStart);
return json.substring(valueStart, valueEnd);
}
}
Gson is Google's JSON library. It maps JSON to Java objects automatically. Add it to your project with Maven (com.google.code.gson:gson:2.10.1) or Gradle:
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
public class JsonWithGson {
// Define a Java class matching the JSON structure
static class Post {
int userId;
int id;
String title;
String body;
@Override
public String toString() {
return "Post{id=" + id + ", title='" + title + "'}";
}
}
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
Gson gson = new Gson();
// GET: Fetch a single post and deserialize to Java object
HttpRequest getRequest = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.build();
HttpResponse getResponse = client.send(getRequest,
HttpResponse.BodyHandlers.ofString());
Post post = gson.fromJson(getResponse.body(), Post.class);
System.out.println("Single post: " + post);
System.out.println("Title: " + post.title);
// Output:
// Single post: Post{id=1, title='sunt aut facere...'}
// Title: sunt aut facere repellat provident occaecati...
// GET: Fetch a list of posts
HttpRequest listRequest = HttpRequest.newBuilder()
.uri(URI.create(
"https://jsonplaceholder.typicode.com/posts?_limit=3"))
.build();
HttpResponse listResponse = client.send(listRequest,
HttpResponse.BodyHandlers.ofString());
// For generic types like List, use TypeToken
Type postListType = new TypeToken>(){}.getType();
List posts = gson.fromJson(listResponse.body(), postListType);
System.out.println("Got " + posts.size() + " posts:");
posts.forEach(p -> System.out.println(" " + p));
// POST: Serialize Java object to JSON and send
Post newPost = new Post();
newPost.userId = 1;
newPost.title = "My New Post";
newPost.body = "Content of the new post";
String jsonBody = gson.toJson(newPost);
System.out.println("Sending JSON: " + jsonBody);
HttpRequest postRequest = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse postResponse = client.send(postRequest,
HttpResponse.BodyHandlers.ofString());
Post createdPost = gson.fromJson(postResponse.body(), Post.class);
System.out.println("Created: " + createdPost);
System.out.println("Assigned ID: " + createdPost.id);
// Output:
// Created: Post{id=101, title='My New Post'}
// Assigned ID: 101
}
}
Jackson is the other major JSON library in the Java ecosystem. It is the default in Spring Boot. Add it with Maven (com.fasterxml.jackson.core:jackson-databind:2.16.1):
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
public class JsonWithJackson {
// Jackson annotation to ignore unknown fields in JSON
@JsonIgnoreProperties(ignoreUnknown = true)
static class User {
public int id;
public String name;
public String email;
public String phone;
@Override
public String toString() {
return "User{id=" + id + ", name='" + name +
"', email='" + email + "'}";
}
}
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
ObjectMapper mapper = new ObjectMapper();
// Fetch and deserialize a single user
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/users/1"))
.build();
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
User user = mapper.readValue(response.body(), User.class);
System.out.println("User: " + user);
// Output: User{id=1, name='Leanne Graham', email='Sincere@april.biz'}
// Fetch a list of users
HttpRequest listRequest = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/users"))
.build();
HttpResponse listResponse = client.send(listRequest,
HttpResponse.BodyHandlers.ofString());
List users = mapper.readValue(listResponse.body(),
new TypeReference>(){});
System.out.println("Total users: " + users.size());
users.stream().limit(3).forEach(u -> System.out.println(" " + u));
// Serialize and send
User newUser = new User();
newUser.name = "John Doe";
newUser.email = "john@example.com";
String json = mapper.writeValueAsString(newUser);
HttpRequest postRequest = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/users"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse postResp = client.send(postRequest,
HttpResponse.BodyHandlers.ofString());
System.out.println("Created: " + postResp.body());
}
}
Gson vs Jackson comparison:
| Feature | Gson | Jackson |
|---|---|---|
| Default in Spring Boot | No | Yes |
| Speed | Fast | Faster |
| Annotations needed | None for basic use | @JsonIgnoreProperties recommended |
| Unknown fields | Ignored by default | Throws by default (configurable) |
| Streaming API | Yes | Yes (more powerful) |
| Tree model | JsonElement |
JsonNode |
HTTP requests can fail in many ways -- network issues, timeouts, invalid URLs, server errors, rate limiting. Robust error handling is what separates production code from tutorial code. The HttpClient API throws specific exceptions for different failure modes.
| Exception | When It Occurs | Example Cause |
|---|---|---|
IOException |
Network-level failure | Connection refused, DNS failure, connection reset |
InterruptedException |
Thread interrupted while waiting | Thread.interrupt() called during send() |
HttpTimeoutException |
Request or connect timeout exceeded | Server too slow, network congestion |
HttpConnectTimeoutException |
Connect timeout specifically | Cannot reach server (firewall, down) |
IllegalArgumentException |
Invalid request configuration | Malformed URI, invalid header |
SecurityException |
Security manager blocks request | Sandbox restrictions |
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
public class ErrorHandling {
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
public static String fetchData(String url) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(10))
.build();
try {
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
// Check HTTP status codes
int status = response.statusCode();
if (status >= 200 && status < 300) {
return response.body();
} else if (status == 401) {
throw new RuntimeException("Unauthorized: check your credentials");
} else if (status == 403) {
throw new RuntimeException("Forbidden: insufficient permissions");
} else if (status == 404) {
throw new RuntimeException("Not found: " + url);
} else if (status == 429) {
// Rate limited -- could implement retry with backoff
String retryAfter = response.headers()
.firstValue("Retry-After").orElse("unknown");
throw new RuntimeException(
"Rate limited. Retry after: " + retryAfter);
} else if (status >= 500) {
throw new RuntimeException(
"Server error " + status + ": " + response.body());
} else {
throw new RuntimeException(
"Unexpected status " + status + ": " + response.body());
}
} catch (HttpConnectTimeoutException e) {
throw new RuntimeException(
"Connection timeout: cannot reach " + url, e);
} catch (HttpTimeoutException e) {
throw new RuntimeException(
"Request timeout: server too slow at " + url, e);
} catch (IOException e) {
throw new RuntimeException(
"Network error connecting to " + url + ": " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restore interrupt flag
throw new RuntimeException("Request interrupted", e);
}
}
public static void main(String[] args) {
// Test with a valid URL
try {
String data = fetchData(
"https://jsonplaceholder.typicode.com/posts/1");
System.out.println("Success: " + data.substring(0, 50) + "...");
} catch (RuntimeException e) {
System.err.println("Failed: " + e.getMessage());
}
// Test with a 404
try {
String data = fetchData(
"https://jsonplaceholder.typicode.com/posts/99999");
System.out.println("Got: " + data);
} catch (RuntimeException e) {
System.err.println("Failed: " + e.getMessage());
}
}
}
For transient failures (network hiccups, 503 Service Unavailable, 429 Rate Limited), retrying with exponential backoff is a production best practice:
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class RetryWithBackoff {
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
public static HttpResponse sendWithRetry(
HttpRequest request, int maxRetries) throws Exception {
int attempt = 0;
while (true) {
try {
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
// Retry on 503 (Service Unavailable) or 429 (Rate Limited)
if ((response.statusCode() == 503 ||
response.statusCode() == 429) && attempt < maxRetries) {
long waitMs = (long) Math.pow(2, attempt) * 1000; // 1s, 2s, 4s...
System.out.println("Retrying in " + waitMs +
"ms (attempt " + (attempt + 1) + "/" + maxRetries + ")");
Thread.sleep(waitMs);
attempt++;
continue;
}
return response;
} catch (IOException e) {
if (attempt < maxRetries) {
long waitMs = (long) Math.pow(2, attempt) * 1000;
System.out.println("Network error, retrying in " +
waitMs + "ms: " + e.getMessage());
Thread.sleep(waitMs);
attempt++;
} else {
throw e; // Max retries exceeded
}
}
}
}
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/posts/1"))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse response = sendWithRetry(request, 3);
System.out.println("Status: " + response.statusCode());
System.out.println("Body: " + response.body().substring(0, 50) + "...");
}
}
One of the headline features of Java 11's HttpClient is native HTTP/2 support. HTTP/2 is a major revision of the HTTP protocol that brings significant performance improvements over HTTP/1.1, especially for applications making many requests to the same server.
HTTP/2 advantages:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Http2Support {
public static void main(String[] args) throws Exception {
// HTTP/2 is the default preference
HttpClient http2Client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.build();
// Force HTTP/1.1 only
HttpClient http1Client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://www.google.com"))
.build();
// With HTTP/2 client
HttpResponse h2Response = http2Client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Requested: HTTP/2");
System.out.println("Actual: " + h2Response.version());
// Output: Actual: HTTP_2
// With HTTP/1.1 client
HttpResponse h1Response = http1Client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Requested: HTTP/1.1");
System.out.println("Actual: " + h1Response.version());
// Output: Actual: HTTP_1_1
// If server does not support HTTP/2, it falls back to HTTP/1.1 automatically
HttpResponse fallbackResponse = http2Client.send(
HttpRequest.newBuilder()
.uri(URI.create("http://httpbin.org/get")) // HTTP (not HTTPS)
.build(),
HttpResponse.BodyHandlers.ofString());
System.out.println("HTTP (not HTTPS): " + fallbackResponse.version());
// May output: HTTP_1_1 (HTTP/2 requires HTTPS for most servers)
}
}
Key point: HTTP/2 is negotiated automatically via TLS ALPN (Application-Layer Protocol Negotiation). If you set HTTP_2 but the server only speaks HTTP/1.1, the client gracefully falls back. You can check which version was actually used via response.version().
With HTTP/1.1, each request needs its own TCP connection (or must wait for the previous response on a shared connection). HTTP/2 multiplexes all requests over a single connection:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class Http2Multiplexing {
public static void main(String[] args) {
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.build();
// Create 20 requests to the same host
List requests = IntStream.rangeClosed(1, 20)
.mapToObj(i -> HttpRequest.newBuilder()
.uri(URI.create(
"https://jsonplaceholder.typicode.com/posts/" + i))
.build())
.collect(Collectors.toList());
Instant start = Instant.now();
// Send all 20 requests in parallel
// With HTTP/2, these all go over a SINGLE TCP connection
List>> futures = requests.stream()
.map(req -> client.sendAsync(req,
HttpResponse.BodyHandlers.ofString()))
.collect(Collectors.toList());
// Wait for all to complete
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.join();
Duration elapsed = Duration.between(start, Instant.now());
long successCount = futures.stream()
.map(CompletableFuture::join)
.filter(r -> r.statusCode() == 200)
.peek(r -> System.out.println("Post " + r.uri().getPath()
+ " - " + r.version()))
.count();
System.out.println("\n20 requests completed in " +
elapsed.toMillis() + " ms");
System.out.println("Successful: " + successCount);
System.out.println("All via HTTP/2 multiplexing on a single connection");
}
}
Let us put everything together into a complete, production-quality REST client. This TodoClient performs all CRUD operations (Create, Read, Update, Delete) against the JSONPlaceholder API. It demonstrates proper error handling, JSON serialization, async operations, and clean API design.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
/**
* A complete REST client for JSONPlaceholder's /todos endpoint.
* Demonstrates all HttpClient features in a real-world pattern.
*/
public class TodoClient {
private static final String BASE_URL =
"https://jsonplaceholder.typicode.com";
private final HttpClient client;
public TodoClient() {
this.client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
// ---- Data class ----
static class Todo {
int userId;
int id;
String title;
boolean completed;
Todo() {}
Todo(int userId, String title, boolean completed) {
this.userId = userId;
this.title = title;
this.completed = completed;
}
String toJson() {
return "{\"userId\":" + userId
+ ",\"title\":\"" + title.replace("\"", "\\\"")
+ "\",\"completed\":" + completed + "}";
}
static Todo fromJson(String json) {
Todo todo = new Todo();
todo.id = extractInt(json, "id");
todo.userId = extractInt(json, "userId");
todo.title = extractString(json, "title");
todo.completed = json.contains("\"completed\":true")
|| json.contains("\"completed\": true");
return todo;
}
@Override
public String toString() {
return "Todo{id=" + id + ", userId=" + userId
+ ", title='" + title + "', completed=" + completed + "}";
}
// Simple JSON extractors (use Gson/Jackson in production)
private static int extractInt(String json, String key) {
String search = "\"" + key + "\":";
int start = json.indexOf(search) + search.length();
// Skip whitespace
while (start < json.length() &&
json.charAt(start) == ' ') start++;
int end = start;
while (end < json.length() &&
Character.isDigit(json.charAt(end))) end++;
return Integer.parseInt(json.substring(start, end));
}
private static String extractString(String json, String key) {
String search = "\"" + key + "\":";
int idx = json.indexOf(search) + search.length();
// Skip whitespace and opening quote
while (idx < json.length() &&
json.charAt(idx) != '"') idx++;
int start = idx + 1;
int end = json.indexOf("\"", start);
return json.substring(start, end);
}
}
// ---- API Methods ----
/** GET /todos/{id} -- Fetch a single todo */
public Todo getTodo(int id) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/todos/" + id))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.GET()
.build();
HttpResponse response = sendWithErrorHandling(request);
return Todo.fromJson(response.body());
}
/** GET /todos?userId={userId} -- Fetch todos by user */
public String getTodosByUser(int userId) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/todos?userId=" + userId
+ "&_limit=5"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.build();
HttpResponse response = sendWithErrorHandling(request);
return response.body();
}
/** POST /todos -- Create a new todo */
public Todo createTodo(int userId, String title,
boolean completed) throws Exception {
Todo todo = new Todo(userId, title, completed);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/todos"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.POST(HttpRequest.BodyPublishers.ofString(todo.toJson()))
.build();
HttpResponse response = sendWithErrorHandling(request);
return Todo.fromJson(response.body());
}
/** PUT /todos/{id} -- Update (replace) a todo */
public Todo updateTodo(int id, int userId, String title,
boolean completed) throws Exception {
Todo todo = new Todo(userId, title, completed);
todo.id = id;
String json = "{\"id\":" + id + ","
+ "\"userId\":" + userId + ","
+ "\"title\":\"" + title.replace("\"", "\\\"") + "\","
+ "\"completed\":" + completed + "}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/todos/" + id))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.PUT(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse response = sendWithErrorHandling(request);
return Todo.fromJson(response.body());
}
/** PATCH /todos/{id} -- Partially update a todo */
public Todo patchTodo(int id, boolean completed) throws Exception {
String json = "{\"completed\":" + completed + "}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/todos/" + id))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.method("PATCH",
HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse response = sendWithErrorHandling(request);
return Todo.fromJson(response.body());
}
/** DELETE /todos/{id} -- Delete a todo */
public int deleteTodo(int id) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/todos/" + id))
.timeout(Duration.ofSeconds(15))
.DELETE()
.build();
HttpResponse response = sendWithErrorHandling(request);
return response.statusCode();
}
/** Async: Fetch multiple todos in parallel */
public List getTodosAsync(List ids) {
List> futures = ids.stream()
.map(id -> {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/todos/" + id))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.build();
return client.sendAsync(request,
HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenApply(Todo::fromJson)
.exceptionally(ex -> {
System.err.println("Failed to fetch todo "
+ id + ": " + ex.getMessage());
return null;
});
})
.collect(Collectors.toList());
CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])).join();
return futures.stream()
.map(CompletableFuture::join)
.filter(todo -> todo != null)
.collect(Collectors.toList());
}
// ---- Error Handling ----
private HttpResponse sendWithErrorHandling(
HttpRequest request) throws Exception {
try {
HttpResponse response = client.send(request,
HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status >= 200 && status < 300) {
return response;
}
throw new RuntimeException("HTTP " + status + ": "
+ response.body());
} catch (HttpTimeoutException e) {
throw new RuntimeException("Request timed out: "
+ request.uri(), e);
} catch (IOException e) {
throw new RuntimeException("Network error: "
+ e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Request interrupted", e);
}
}
// ---- Main: Demonstrate all operations ----
public static void main(String[] args) throws Exception {
TodoClient todoClient = new TodoClient();
System.out.println("=== CRUD Operations Demo ===\n");
// CREATE
System.out.println("--- CREATE ---");
Todo created = todoClient.createTodo(1, "Learn HttpClient", false);
System.out.println("Created: " + created);
// READ (single)
System.out.println("\n--- READ (single) ---");
Todo todo = todoClient.getTodo(1);
System.out.println("Fetched: " + todo);
// READ (by user)
System.out.println("\n--- READ (by user) ---");
String userTodos = todoClient.getTodosByUser(1);
System.out.println("User 1 todos (first 100 chars): "
+ userTodos.substring(0, Math.min(100, userTodos.length()))
+ "...");
// UPDATE (full)
System.out.println("\n--- UPDATE ---");
Todo updated = todoClient.updateTodo(1, 1,
"Learn HttpClient (Updated)", true);
System.out.println("Updated: " + updated);
// PATCH (partial)
System.out.println("\n--- PATCH ---");
Todo patched = todoClient.patchTodo(1, true);
System.out.println("Patched: " + patched);
// DELETE
System.out.println("\n--- DELETE ---");
int deleteStatus = todoClient.deleteTodo(1);
System.out.println("Delete status: " + deleteStatus);
// ASYNC (parallel fetch)
System.out.println("\n--- ASYNC (parallel) ---");
List todos = todoClient.getTodosAsync(
List.of(1, 5, 10, 15, 20));
System.out.println("Fetched " + todos.size()
+ " todos in parallel:");
todos.forEach(t -> System.out.println(" " + t));
System.out.println("\n=== All operations completed ===");
}
}
// Output:
// === CRUD Operations Demo ===
//
// --- CREATE ---
// Created: Todo{id=201, userId=1, title='Learn HttpClient', completed=false}
//
// --- READ (single) ---
// Fetched: Todo{id=1, userId=1, title='delectus aut autem', completed=false}
//
// --- READ (by user) ---
// User 1 todos (first 100 chars): [
// {
// "userId": 1,
// "id": 1,
// "title": "delectus aut autem",
// "completed": fals...
//
// --- UPDATE ---
// Updated: Todo{id=1, userId=1, title='Learn HttpClient (Updated)', completed=true}
//
// --- PATCH ---
// Patched: Todo{id=1, userId=1, title='delectus aut autem', completed=true}
//
// --- DELETE ---
// Delete status: 200
//
// --- ASYNC (parallel) ---
// Fetched 5 todos in parallel:
// Todo{id=1, userId=1, title='delectus aut autem', completed=false}
// Todo{id=5, userId=1, title='laboriosam mollitia...', completed=false}
// Todo{id=10, userId=1, title='illo est ratione...', completed=true}
// Todo{id=15, userId=1, title='ab voluptatum amet...', completed=true}
// Todo{id=20, userId=1, title='ullam nobis libero...', completed=false}
//
// === All operations completed ===
If you are maintaining a legacy codebase that still uses HttpURLConnection, here is a detailed comparison to help you decide whether to migrate:
| Feature | HttpURLConnection (Java 1.1) | HttpClient (Java 11) |
|---|---|---|
| API Style | Setter-based, mutable | Builder pattern, immutable |
| HTTP/2 | No support | Full support with auto-fallback |
| Async Requests | No (manual thread management) | Built-in with CompletableFuture |
| WebSocket | No support | Built-in WebSocket API |
| Thread Safety | Not thread-safe | Fully thread-safe and immutable |
| Connection Pooling | Limited (keep-alive) | Full connection pooling built in |
| Request Body | Manual OutputStream writing | BodyPublishers (String, File, byte[]...) |
| Response Body | Manual InputStream reading | BodyHandlers (String, File, Stream...) |
| Timeouts | setConnectTimeout/setReadTimeout | connectTimeout + per-request timeout |
| Redirect Handling | setFollowRedirects (static, global) | Per-client configuration with policies |
| Error Streams | getInputStream vs getErrorStream confusion | Unified response body regardless of status |
| Code Lines (GET) | 15-20 lines | 5-7 lines |
| Cookie Management | Manual | Built-in CookieHandler integration |
| SSL/TLS | Cast to HttpsURLConnection | SSLContext on builder |
Should you migrate? If you are on Java 11+, yes. The new HttpClient is better in every measurable way. The only reason to keep HttpURLConnection is if you are stuck on Java 8 and cannot upgrade. Even then, consider Apache HttpClient or OkHttp as alternatives.
These best practices come from building production HTTP clients that handle millions of requests:
1. Reuse the HttpClient instance
Create one HttpClient per application (or per service endpoint) and share it. The client manages connection pooling internally. Creating a new client per request is like opening a new database connection for every query.
// WRONG: New client per request
public String fetchData(String url) throws Exception {
HttpClient client = HttpClient.newHttpClient(); // Wasteful!
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
}
// RIGHT: Shared client instance
public class ApiService {
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
public String fetchData(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url)).build();
return CLIENT.send(request,
HttpResponse.BodyHandlers.ofString()).body();
}
}
2. Always set timeouts
A missing timeout means your thread can block forever. Set both the connection timeout (on the client) and the request timeout (on each request).
// WRONG: No timeouts -- can block forever
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://slow-server.com/api"))
.build();
// RIGHT: Both timeouts set
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) // TCP connection timeout
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://slow-server.com/api"))
.timeout(Duration.ofSeconds(30)) // Full request timeout
.build();
3. Use async for multiple independent requests
If you need to call 5 APIs and none depends on the others, send them all with sendAsync() and wait with allOf(). This turns 5 seconds of sequential calls into 1 second of parallel calls.
4. Always handle errors and check status codes
HTTP "errors" (4xx, 5xx) are not Java exceptions. The send() method returns successfully for any HTTP status. You must check response.statusCode() yourself.
5. Use a custom executor for I/O-bound async work
The default executor is a common pool with limited threads. For high-concurrency HTTP clients, provide your own executor:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
// For high-concurrency HTTP workloads
ExecutorService executor = Executors.newFixedThreadPool(20);
HttpClient client = HttpClient.newBuilder()
.executor(executor)
.build();
// Remember to shut down the executor when done
// executor.shutdown();
6. Use BodyHandlers.ofFile() for large downloads
Do not read a 500MB file into a String. Use ofFile() or ofInputStream() to stream large responses directly to disk.
7. Close InputStream responses
If you use BodyHandlers.ofInputStream(), close the stream when done. String and byte[] handlers do not require cleanup.
8. Restore interrupt flag
When catching InterruptedException, always call Thread.currentThread().interrupt() to restore the interrupt flag.
Summary table:
| Practice | Do | Do Not |
|---|---|---|
| Client instance | Reuse one client across requests | Create a new client per request |
| Timeouts | Set connect + request timeouts | Rely on defaults (no timeout) |
| Parallel calls | Use sendAsync + allOf | Sequential send() in a loop |
| Status codes | Check statusCode() and handle 4xx/5xx | Assume success after send() |
| Large responses | Use ofFile() or ofInputStream() | Read everything into a String |
| Thread pools | Custom executor for high concurrency | Use common pool for 100+ concurrent requests |
| InterruptedException | Restore interrupt flag | Swallow the exception |
| JSON | Use Gson or Jackson | Parse JSON with regex or indexOf |
This table summarizes all the key HttpClient operations for quick lookup:
| Operation | Code |
|---|---|
| Create default client | HttpClient.newHttpClient() |
| Create configured client | HttpClient.newBuilder().connectTimeout(...).build() |
| GET request | HttpRequest.newBuilder().uri(URI.create(url)).GET().build() |
| POST with JSON | .POST(BodyPublishers.ofString(json)) |
| PUT | .PUT(BodyPublishers.ofString(json)) |
| DELETE | .DELETE() |
| PATCH | .method("PATCH", BodyPublishers.ofString(json)) |
| Set headers | .header("Accept", "application/json") |
| Set timeout | .timeout(Duration.ofSeconds(30)) |
| Sync send | client.send(request, BodyHandlers.ofString()) |
| Async send | client.sendAsync(request, BodyHandlers.ofString()) |
| Response body as String | BodyHandlers.ofString() |
| Response body as File | BodyHandlers.ofFile(Path.of("out.json")) |
| Response body as bytes | BodyHandlers.ofByteArray() |
| Response body as Stream | BodyHandlers.ofLines() |
| Discard body | BodyHandlers.discarding() |
| Status code | response.statusCode() |
| Response header | response.headers().firstValue("content-type") |
| Parallel requests | CompletableFuture.allOf(futures).join() |
| Error handling | .exceptionally(ex -> fallback) |
| HTTP/2 | .version(HttpClient.Version.HTTP_2) |
| Follow redirects | .followRedirects(HttpClient.Redirect.NORMAL) |
| Cookie support | .cookieHandler(new CookieManager()) |