Java 11 HttpClient API

1. Introduction

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?

  • Designed in 1997 for HTTP/1.0 — before REST APIs, JSON, or microservices existed
  • No builder pattern — configuration through scattered setter methods
  • No native async support — blocking I/O only
  • Manual stream management for reading responses
  • No HTTP/2 support
  • Confusing error handling — getInputStream() throws on 4xx/5xx but getErrorStream() does not
  • No built-in timeout configuration at the request level

What Java 11 HttpClient provides:

  • Fluent builder API — clean, readable code with method chaining
  • Synchronous and asynchronous requests out of the box
  • HTTP/2 support with automatic fallback to HTTP/1.1
  • WebSocket support built in
  • Immutable and thread-safe — one client instance shared across your application
  • CompletableFuture integration for async operations
  • BodyHandlers and BodyPublishers for flexible request/response body handling

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.

2. Creating an HttpClient

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.

2.1 Quick Client (Defaults)

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

2.2 Configured Client (Builder Pattern)

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

2.3 Client with Cookie Management

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

2.4 Client with Basic Authentication

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.

3. Sending GET Requests

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.

3.1 Basic GET Request

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:

  1. Build the requestHttpRequest.newBuilder() starts a builder. Set the URI, method, headers, and timeout.
  2. Send the requestclient.send() is synchronous (blocks until response arrives). The second parameter is a BodyHandler that tells the client how to process the response body.
  3. Read the responseresponse.statusCode() gives the HTTP status code. response.body() gives the body in whatever format the BodyHandler specified (here, String).

3.2 GET with Headers and Timeout

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.

3.3 GET with Response to File

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
    }
}

4. Sending POST Requests

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.

4.1 POST with String Body (JSON)

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.

4.2 POST with Form Data

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 }
    }
}

4.3 POST with File Body

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

4.4 POST with Byte Array

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

5. Handling Responses

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().

5.1 Response Basics

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

5.2 BodyHandlers

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

6. Asynchronous Requests

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.

6.1 Basic Async Request

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

6.2 Chaining Async Operations

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
    }
}

6.3 Parallel Requests with allOf

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

6.4 Async with Error Handling

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: ...
    }
}

7. PUT, DELETE, and Other Methods

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.

7.1 PUT Request

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}
    }
}

7.2 DELETE Request

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: {}
    }
}

7.3 PATCH Request

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..."}
    }
}

7.4 HEAD Request

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

8. Query Parameters and Headers

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.

8.1 Building URLs with Query Parameters

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
    }
}

8.2 Custom Headers

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

9. Handling JSON

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.

9.1 Manual JSON Parsing (No Dependencies)

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);
    }
}

9.2 JSON with Gson

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
    }
}

9.3 JSON with Jackson

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

10. Error Handling

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.

10.1 Exception Types

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

10.2 Comprehensive Error Handling

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

10.3 Retry with Exponential Backoff

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) + "...");
    }
}

11. HTTP/2 Support

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:

  • Multiplexing -- multiple requests and responses over a single TCP connection, no head-of-line blocking
  • Header compression -- HPACK compression reduces overhead for repeated headers
  • Binary protocol -- more efficient parsing than HTTP/1.1's text-based format
  • Server push -- server can proactively send resources (though browser support is declining)
  • Stream prioritization -- clients can hint which responses matter most

11.1 Version Negotiation

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().

11.2 Multiplexing Benefit

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");
    }
}

12. Real-World REST Client Example

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 ===

13. HttpClient vs HttpURLConnection

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.

14. Best Practices

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

15. Quick Reference

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



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *