Networking & HTTP

The Modern HttpClient

15 min Lesson 4 of 13

The Modern HttpClient

Before Java 11, making an HTTP request from Java code required either the cumbersome HttpURLConnection API (verbose, blocking, and full of gotchas) or a third-party library such as Apache HttpClient or OkHttp. Java 11 shipped java.net.http — a modern, standards-aligned HTTP client built into the JDK. It supports HTTP/1.1 and HTTP/2, WebSockets, fully non-blocking I/O through CompletableFuture, and a clean fluent builder API.

This lesson covers the two fundamental pieces of the API: building an HttpClient and constructing an HttpRequest. Sending requests and handling responses are the focus of the next lesson.

The Core Design: Immutable Value Objects Built with Builders

The entire API centres on two immutable types:

  • HttpClient — a thread-safe, reusable HTTP engine. Create one per application (or per logical concern) and reuse it for all requests. Heavy construction cost is paid once.
  • HttpRequest — an immutable description of a single request (URI, method, headers, body). Build a new one for each call.

Both are constructed with nested Builder inner classes following the standard Java builder pattern you already know from the Streams and Collections APIs. Once built, instances are immutable and therefore safe to share across threads.

Why immutability? An HttpClient carries connection pools, SSL context, and executor state. Making it immutable eliminates accidental shared-state bugs in concurrent code — you configure it once and trust it never changes.

Creating an HttpClient

The simplest possible client uses all defaults:

import java.net.http.HttpClient; HttpClient client = HttpClient.newHttpClient();

Defaults give you HTTP/2 with HTTP/1.1 fallback, the common JVM SSL context, and a daemon thread pool. That is enough for many applications. When you need to customise behaviour, reach for the builder:

import java.net.http.HttpClient; import java.net.http.HttpClient.Redirect; import java.net.http.HttpClient.Version; import java.time.Duration; import java.util.concurrent.Executors; HttpClient client = HttpClient.newBuilder() .version(Version.HTTP_2) // prefer HTTP/2, fallback to 1.1 .followRedirects(Redirect.NORMAL) // follow 3xx (not cross-protocol) .connectTimeout(Duration.ofSeconds(10)) // fail fast on unreachable hosts .executor(Executors.newVirtualThreadPerTaskExecutor()) // Java 21+: virtual threads .build();

Key HttpClient Configuration Options

  • version()Version.HTTP_2 (default) or Version.HTTP_1_1. HTTP/2 multiplexes multiple requests over a single TCP connection, giving lower latency at scale. The client negotiates via ALPN and falls back gracefully.
  • followRedirects()NEVER, ALWAYS, or NORMAL (follows all redirects except HTTPS to HTTP downgrade). NORMAL is the safe production choice.
  • connectTimeout() — how long to wait for the initial TCP connection. Prevents threads from hanging indefinitely against a firewall-dropped endpoint. Note: this is connection timeout only; read/write timeouts are set per-request.
  • executor() — the thread pool for async operations. On Java 21+ passing Executors.newVirtualThreadPerTaskExecutor() lets you fire thousands of concurrent HTTP calls cheaply without tuning a platform thread pool.
  • sslContext() — supply a custom SSLContext when you need mutual TLS, a custom trust store, or self-signed certificates in a test environment.
  • authenticator() — an Authenticator for HTTP Basic/Digest challenges (rare in modern APIs; most use bearer tokens in headers instead).
  • cookieHandler() — plug in a CookieManager for session-cookie workflows. Default: no cookies stored.
  • proxy() — route traffic through a corporate proxy: ProxySelector.of(new InetSocketAddress("proxy.corp.com", 8080)).
Treat HttpClient as an application-scoped singleton. Creating one per request wastes connection pool setup and defeats HTTP/2 connection reuse. Inject it via a framework DI container or store it in a static final field if needed.

Building an HttpRequest

An HttpRequest bundles everything that describes a single HTTP call: the target URI, the HTTP method, headers, an optional body publisher, and an optional per-request timeout. Build one with HttpRequest.newBuilder():

import java.net.URI; import java.net.http.HttpRequest; import java.time.Duration; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products/42")) .GET() // explicit GET (also the default) .header("Accept", "application/json") .header("Authorization", "Bearer " + token) .timeout(Duration.ofSeconds(30)) .build();

Setting timeout() on the request means: if the server does not complete the response within that window the client throws an HttpTimeoutException. This is distinct from the connect timeout on the client — you typically want both.

HTTP Methods and Body Publishers

Use the method-specific builder methods. GET and DELETE carry no body. POST, PUT, and PATCH carry a body expressed as an HttpRequest.BodyPublisher:

import java.net.http.HttpRequest.BodyPublishers; import java.nio.file.Path; // POST with a JSON body from a String HttpRequest postRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products")) .POST(BodyPublishers.ofString("{\"name\":\"Widget\",\"price\":9.99}")) .header("Content-Type", "application/json") .header("Accept", "application/json") .timeout(Duration.ofSeconds(30)) .build(); // PUT with a body from a file (streaming — no full file in memory) Path filePath = Path.of("/data/payload.json"); HttpRequest putRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products/42")) .PUT(BodyPublishers.ofFile(filePath)) .header("Content-Type", "application/json") .build(); // DELETE — no body publisher needed HttpRequest deleteRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products/42")) .DELETE() .header("Authorization", "Bearer " + token) .build();

The most useful built-in BodyPublisher factories in BodyPublishers:

  • ofString(String) — UTF-8 string body. The most common choice for JSON payloads.
  • ofFile(Path) — streams a file directly from disk with no heap buffering. Use this for large uploads.
  • ofByteArray(byte[]) — raw bytes. Useful when you already serialized the payload.
  • noBody() — explicitly marks a request body as absent. Equivalent to omitting a body publisher.

Setting Multiple Headers and Overriding Headers

The builder provides both header(name, value) (which adds a header, supporting multi-value headers) and headers(name, value, name, value, ...) for bulk setting. To replace a previously set value use setHeader(name, value):

HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/search")) .GET() // bulk header setting — name/value pairs in sequence .headers( "Accept", "application/json", "Accept-Language", "en-US,ar;q=0.9", "X-Client-Id", "my-service-v2" ) .timeout(Duration.ofSeconds(20)) .build();
Do not set restricted headers such as Host, Content-Length, or Connection manually. The JDK computes and enforces them. Attempting to set them throws an IllegalArgumentException at request-build time. Check the HttpRequest Javadoc for the full restricted list.

Reusing a Request Template with copy()

Because HttpRequest is immutable, you cannot modify one after building it. However, you can copy a builder from an existing request and override specific fields:

HttpRequest base = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/v1/products/42")) .header("Authorization", "Bearer " + token) .header("Accept", "application/json") .timeout(Duration.ofSeconds(30)) .build(); // derive a POST variant without rewriting all the common config HttpRequest createRequest = HttpRequest.newBuilder(base, (name, value) -> true) .uri(URI.create("https://api.example.com/v1/products")) .POST(BodyPublishers.ofString("{\"name\":\"Gadget\"}")) .build();

The copy constructor HttpRequest.newBuilder(existingRequest, headerFilter) clones all settings; the filter lambda selects which headers to carry over (true keeps all).

Putting It Together: A Minimal Service Pattern

public class ProductApiClient { private static final HttpClient HTTP = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) .followRedirects(HttpClient.Redirect.NORMAL) .connectTimeout(Duration.ofSeconds(10)) .build(); private static final String BASE_URL = "https://api.example.com/v1"; private final String bearerToken; public ProductApiClient(String bearerToken) { this.bearerToken = bearerToken; } private HttpRequest.Builder authorisedBuilder(String path) { return HttpRequest.newBuilder() .uri(URI.create(BASE_URL + path)) .header("Accept", "application/json") .header("Authorization", "Bearer " + bearerToken) .timeout(Duration.ofSeconds(30)); } public HttpRequest buildGetProduct(long id) { return authorisedBuilder("/products/" + id).GET().build(); } public HttpRequest buildCreateProduct(String jsonBody) { return authorisedBuilder("/products") .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) .header("Content-Type", "application/json") .build(); } }

This pattern separates the description of a request from the sending of it, which makes the builder methods trivially unit-testable without any network calls.

Summary

HttpClient is a reusable, thread-safe engine — create it once and share it. HttpRequest is an immutable per-call description — build a new one each time. Configure the client with version, redirect policy, connect timeout, and an executor. Configure the request with URI, method, headers, body publisher, and per-request timeout. Use BodyPublishers to supply bodies from strings, files, or byte arrays. With both objects in hand, the next step is sending the request and processing the response.