☕ Java

HTTP Client API

The java.net.http HTTP Client API, finalized as a standard feature in Java 11 (after an incubating preview in Java 9–10), provides a modern, fluent builder-based client for making HTTP requests, supporting both HTTP/1.1 and HTTP/2, synchronous and fully asynchronous (CompletableFuture-based) request execution, and proper streaming request/response bodies — replacing the old java.net.HttpURLConnection, which had been part of the JDK since Java 1.1 and was widely regarded as low-level, awkward to use correctly, and entirely unaware of HTTP/2. This entry covers exactly what was wrong with HttpURLConnection that motivated a replacement, the HttpClient/HttpRequest/HttpResponse builder API and how it's actually used for both sync and async calls, BodyPublishers/BodyHandlers for request and response bodies, and HTTP/2 support including server push.

Motivation — Why HttpURLConnection Needed Replacing

java.net.HttpURLConnection, present in the JDK since Java 1.1, was designed around an abstract URLConnection model intended to generically support many different URL schemes, not specifically HTTP — and this generality came at a real cost to HTTP-specific usability. Making even a simple GET request required manually opening a connection, casting it to HttpURLConnection, setting properties through a sprawling collection of setter methods, manually managing the InputStream/OutputStream lifecycle, and writing custom logic to read the response body, all using an imperative, connection-state-mutation style that was easy to get subtly wrong (forgetting to call connect(), not properly closing streams, mishandling error response codes which throw an IOException on getInputStream() rather than simply returning an error status the way a modern client would). There was no built-in support for asynchronous, non-blocking requests at all — every call blocked the calling thread until the full response was received, requiring manual thread or executor management for any concurrent HTTP usage. HttpURLConnection also predates HTTP/2 entirely (the HTTP/2 RFC wasn't published until 2015) and was never updated to support it, since its design assumptions were built around HTTP/1.1's request/response model. Java 9 introduced an incubating (non-final, subject to change) HttpClient API specifically to address this gap, and Java 11 finalized it as java.net.http.HttpClient, a standard, supported part of the JDK going forward. The new API was built from the ground up around a fluent builder pattern specific to HTTP semantics, native support for both HTTP/1.1 and HTTP/2 (negotiated automatically per-request, with automatic fallback to HTTP/1.1 if the server doesn't support HTTP/2), and first-class asynchronous execution via CompletableFuture, allowing non-blocking HTTP calls to be composed naturally with the rest of Java's modern asynchronous programming model.
Java
// ── The old way — HttpURLConnection, verbose and error-prone ────────────
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);

int status = conn.getResponseCode();
StringBuilder responseBody = new StringBuilder();
try (BufferedReader br = new BufferedReader(
        new InputStreamReader(
            status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream()))) {
    String line;
    while ((line = br.readLine()) != null) {
        responseBody.append(line);
    }
}
conn.disconnect();
// Manual stream handling, manual error-stream-vs-input-stream switching,
// no async option at all, no HTTP/2 awareness whatsoever

// ── The new way — java.net.http.HttpClient, Java 11+ ────────────────────
HttpClient client = HttpClient.newHttpClient();   // HTTP/2 by default, falls back to 1.1 if needed

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users/1"))
    .header("Accept", "application/json")
    .timeout(Duration.ofSeconds(5))
    .GET()
    .build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
// Fluent builder, status code returned directly (no IOException-on-error-status surprise),
// body handling abstracted via BodyHandlers, HTTP/2 negotiated automatically

Building Requests, Synchronous and Asynchronous Execution

HttpClient instances are created via a builder (HttpClient.newBuilder()...build()) or the convenience HttpClient.newHttpClient() for default settings, and are explicitly designed to be reused across many requests — an HttpClient holds and manages connection pooling, so creating a fresh client per request (the same mistake commonly made with HttpURLConnection-based code, and with other heavyweight clients) discards that pooling benefit and should be avoided; a single shared HttpClient instance, configured once with desired timeouts/redirect policy/executor, is the intended usage pattern for an application or service. HttpRequest is built via HttpRequest.newBuilder(), specifying the target URI, HTTP method (GET, POST, PUT, DELETE, etc., each with a corresponding builder method), headers, timeout, and a request body for methods that carry one. Execution offers two distinct methods on HttpClient: send(request, bodyHandler), which blocks the calling thread until the complete response is received and returns an HttpResponse<T> directly, and sendAsync(request, bodyHandler), which returns immediately with a CompletableFuture<HttpResponse<T>>, allowing the calling thread to continue other work and compose further asynchronous processing (via thenApply, thenAccept, thenCompose, and the rest of CompletableFuture's combinator API) once the response actually arrives, without blocking anything in the meantime. The asynchronous path is where the API's design pays off most clearly relative to HttpURLConnection: multiple requests can be issued concurrently from a single thread without manual thread management, with CompletableFuture's combinators handling composition, error propagation (exceptionally, handle), and timeout behavior (orTimeout, available on CompletableFuture generally) in a uniform, well-understood way that fits naturally alongside any other CompletableFuture-based code already in the application.
Java
// ── Building a reusable client — NOT one client per request ────────────
HttpClient client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)          // request HTTP/2; falls back to 1.1 automatically
    .connectTimeout(Duration.ofSeconds(10))
    .followRedirects(HttpClient.Redirect.NORMAL)
    .build();
// Keep and reuse this single "client" instance across the application's lifetime —
// it manages connection pooling internally

// ── Building requests for different HTTP methods ───────────────────────
HttpRequest getReq = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users"))
    .GET()
    .build();

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

// ── Synchronous execution — blocks until full response received ────────
HttpResponse<String> syncResponse = client.send(getReq, HttpResponse.BodyHandlers.ofString());
System.out.println(syncResponse.statusCode() + ": " + syncResponse.body());

// ── Asynchronous execution — non-blocking, CompletableFuture-based ──────
CompletableFuture<HttpResponse<String>> futureResponse =
    client.sendAsync(getReq, HttpResponse.BodyHandlers.ofString());

futureResponse
    .thenApply(HttpResponse::body)
    .thenAccept(body -> System.out.println("Got body: " + body))
    .exceptionally(ex -> { System.err.println("Request failed: " + ex.getMessage()); return null; });

// Multiple concurrent requests, no manual thread management:
List<CompletableFuture<HttpResponse<String>>> all = List.of(
    client.sendAsync(getReq, HttpResponse.BodyHandlers.ofString()),
    client.sendAsync(postReq, HttpResponse.BodyHandlers.ofString())
);
CompletableFuture.allOf(all.toArray(new CompletableFuture[0])).join();

BodyPublishers, BodyHandlers, and HTTP/2 Server Push

Request and response bodies are handled through two complementary, symmetric abstractions. HttpRequest.BodyPublishers provides factory methods that turn application data into a Flow.Publisher<ByteBuffer> suitable for sending as a request body — ofString(...) for plain text/JSON bodies, ofFile(path) for streaming a file's contents without first loading it entirely into memory, ofByteArray(...) for raw bytes, and noBody() for requests (like most GET requests) with no body at all. The use of the reactive Flow.Publisher abstraction (java.util.concurrent.Flow, also new in Java 9, modeled on the Reactive Streams specification) allows large request bodies to be streamed incrementally rather than buffered entirely in memory before transmission begins. Symmetrically, HttpResponse.BodyHandlers provides factory methods describing how the response body should be consumed and converted: ofString() collects the whole body as a String, ofByteArray() as a raw byte array, ofFile(path) streams the response body directly to disk without buffering it entirely in memory (important for large downloads), ofInputStream() exposes the body as a lazily-readable InputStream for the caller to consume incrementally, and discarding() simply discards the body entirely when only the status code or headers matter. Choosing the right BodyHandler for the expected response size and use case is a meaningful performance consideration — ofString() and ofByteArray() both fully buffer the response in memory before returning, which is fine for typical API responses but inappropriate for very large downloads, where ofFile(...) or ofInputStream() avoid that full in-memory buffering. The API also supports HTTP/2 server push, a feature where a server can proactively send additional resources (such as a stylesheet or script associated with a requested HTML page) alongside the response to the original request, without the client having to issue separate requests for them. This is exposed via an optional PushPromiseHandler passed to sendAsync(...), which receives callbacks for each pushed resource the server provides, allowing the client to handle proactively-pushed content using the same BodyHandler abstraction as ordinary responses — a capability with no equivalent at all in the HTTP/1.1-only HttpURLConnection model, since server push is intrinsically an HTTP/2 protocol feature.
Java
// ── BodyPublishers — constructing request bodies ───────────────────────
HttpRequest jsonPost = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"Bob\"}"))
    .build();

HttpRequest fileUpload = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/upload"))
    .POST(HttpRequest.BodyPublishers.ofFile(Path.of("large-file.zip")))   // streamed, not fully buffered
    .build();

HttpRequest noBodyDelete = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users/1"))
    .DELETE()   // implicitly uses BodyPublishers.noBody()
    .build();

// ── BodyHandlers — choosing how the response body is consumed ──────────
HttpResponse<String> stringResp = client.send(jsonPost, HttpResponse.BodyHandlers.ofString());

HttpResponse<Path> fileResp = client.send(
    HttpRequest.newBuilder().uri(URI.create("https://example.com/big-report.pdf")).build(),
    HttpResponse.BodyHandlers.ofFile(Path.of("downloaded-report.pdf"))   // streamed straight to disk
);

HttpResponse<InputStream> streamResp = client.send(
    HttpRequest.newBuilder().uri(URI.create("https://example.com/data")).build(),
    HttpResponse.BodyHandlers.ofInputStream()   // consume incrementally, e.g. for very large bodies
);
try (InputStream body = streamResp.body()) {
    body.transferTo(System.out);
}

HttpResponse<Void> statusOnly = client.send(
    HttpRequest.newBuilder().uri(URI.create("https://example.com/ping")).build(),
    HttpResponse.BodyHandlers.discarding()   // only the status code matters here
);

// ── HTTP/2 server push — handling proactively-pushed resources ─────────
HttpResponse<String> mainResponse = client.send(
    HttpRequest.newBuilder().uri(URI.create("https://example.com/page.html")).build(),
    HttpResponse.BodyHandlers.ofString()
);

CompletableFuture<HttpResponse<String>> withPush = client.sendAsync(
    HttpRequest.newBuilder().uri(URI.create("https://example.com/page.html")).build(),
    HttpResponse.BodyHandlers.ofString(),
    (pushReq, acceptor) -> {
        // Called once per resource the server proactively pushes (e.g. style.css):
        CompletableFuture<HttpResponse<String>> pushed =
            acceptor.apply(HttpResponse.BodyHandlers.ofString());
        pushed.thenAccept(r -> System.out.println("Pushed resource: " + pushReq.uri() + " -> " + r.body().length() + " bytes"));
    }
);

Related Topics in Java 9 to 21 Features

Module System (JPMS)
The Java Platform Module System (JPMS), introduced in Java 9 under Project Jigsaw, adds a module as a new, higher-level unit of code organization above the package, allowing a JAR-like artifact to explicitly declare which packages it exports for use by other modules, which modules it depends on, and which packages it keeps entirely internal and inaccessible from outside — closing a long-standing gap in the classpath model where every public class in every JAR on the classpath was accessible to every other JAR, regardless of whether that access was intended. JPMS was also the mechanism used to decompose the JDK itself, which had grown into one monolithic rt.jar, into roughly 90 separate modules, enabling smaller custom runtime images and explicit internal-API encapsulation (notably restricting access to sun.* and other internal packages that many libraries had relied on despite warnings). This entry covers the classpath problems JPMS solves, module-info.java syntax and the requires/exports/opens directives, the distinction between the classpath and module path including automatic and unnamed modules, and jlink custom runtime images.
JShell
JShell, introduced in Java 9, is an interactive Read-Eval-Print Loop (REPL) bundled directly with the JDK, allowing Java statements, expressions, method, class, and variable declarations to be entered and evaluated one at a time at an interactive prompt, with immediate feedback — without requiring a class wrapper, a main method, explicit compilation, or a build tool, all of which Java had always required for even the smallest snippet of executable code before Java 9. This solved a long-standing friction point for learning, quick experimentation, and API exploration, where languages like Python or Ruby offered an immediate interactive prompt but Java required the full edit-compile-run cycle even to test a single line. This entry covers what problem JShell solves and how it differs from a script, core REPL usage including implicit variable creation and forward references, snippet management commands, and how to feed JShell external classes and files.
Collection Factory Methods
Collection factory methods — List.of(...), Set.of(...), and Map.of(...)/Map.ofEntries(...) — introduced in Java 9, provide a concise, standard way to create small, fixed-content, genuinely immutable collections in a single expression, replacing the previously common but verbose combination of Arrays.asList(...), Collections.unmodifiableList(...), and manual add() calls, none of which produced a collection that was both concise to write and fully immutable. This entry covers exactly what problem these factories solve compared to the older idioms, the specific immutability and null-rejection guarantees they provide (which are stricter than Collections.unmodifiableXxx), their behavior around duplicate keys/elements, and important caveats around mutability expectations and performance for very small collections.
Private Interface Methods
Private interface methods, introduced in Java 9, allow an interface to declare methods marked private — with a full method body — that are accessible only from within that same interface (from its default methods, its other private methods, and its private static methods), but completely invisible to implementing classes and to any code outside the interface. They close a code-duplication gap left after Java 8's default methods: when an interface has multiple default methods that share common logic, that shared logic previously either had to be duplicated in each default method or exposed as a public default method purely to enable reuse, polluting the interface's actual API surface with a method meant only for internal reuse. This entry covers the specific problem they solve, the distinction between private instance and private static interface methods, the rules governing what they can call and be called from, and how they interact with the broader default-method design Java established in version 8.