Java 11 shipped a real HTTP client in the JDK. Before it, making an HTTP call meant either
HttpURLConnection — an API from 1997 that nobody enjoyed — or adding Apache HttpClient
or OkHttp as a dependency. Now a simple GET is four lines with nothing on the classpath.
Three objects
The whole API is a client, a request and a response. Learn those three and the rest is detail:
class Demo {
void run() throws IOException, InterruptedException {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/items"))
.GET()
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode()); // 200
System.out.println(response.body());
}
}
The client is immutable, thread-safe and holds a connection pool, so create one and reuse it. Building a new client per request throws away pooling and is the single most common misuse.
BodyHandlers decides what the body becomes — a String, a file, an
InputStream, or nothing at all. That choice is a type parameter on the response, which
is why HttpResponse<String> reads the way it does.
Configuring the client
class Demo {
HttpClient build() {
return HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // the default anyway
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL) // default is NEVER
.build();
}
}
Two defaults worth knowing because they surprise people. Redirects are not followed
unless you ask — a 301 comes back as a 301. And connectTimeout bounds establishing the
connection only; bounding the whole request is a separate setting on the request itself.
POST, PUT, DELETE and headers
class Demo {
void run(HttpClient client) throws IOException, InterruptedException {
String json = """
{"name": "Folau", "role": "engineer"}
""";
HttpRequest post = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/users"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer token123")
.timeout(Duration.ofSeconds(30)) // the whole request
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response =
client.send(post, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
HttpRequest delete = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/users/1"))
.DELETE()
.build();
HttpRequest put = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/users/1"))
.PUT(HttpRequest.BodyPublishers.ofString(json))
.build();
}
}
BodyPublishers is the mirror of BodyHandlers — it turns something into a
request body. ofString covers most cases; ofFile and
noBody cover the rest. Text blocks, added in Java 15, make inline JSON far more
readable than the escaped string this example would have needed in Java 11 itself.
Asynchronous requests
class Demo {
void run(HttpClient client) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/items"))
.build();
CompletableFuture<String> future =
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.exceptionally(e -> "request failed: " + e.getMessage());
System.out.println(future.join());
}
}
sendAsync returns a CompletableFuture,
which is where that post pays off: several independent calls can run at once by starting them all
before joining any of them, and the whole chaining and error-handling vocabulary applies
unchanged.
class Demo {
void fetchAll(HttpClient client, List<String> urls) {
List<CompletableFuture<String>> calls = urls.stream()
.map(url -> HttpRequest.newBuilder().uri(URI.create(url)).build())
.map(r -> client.sendAsync(r, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body))
.toList(); // all started here
CompletableFuture.allOf(calls.toArray(CompletableFuture[]::new)).join();
calls.forEach(c -> System.out.println(c.join().length()));
}
}
Errors, and what counts as one
This is the part that catches everyone: a 404 or a 500 is not an exception. The request succeeded — the server answered — so you get a response object with that status code. Only a transport failure throws:
class Demo {
String fetch(HttpClient client, HttpRequest request) {
try {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) { // you must check
throw new IllegalStateException(
"HTTP " + response.statusCode() + ": " + response.body());
}
return response.body();
} catch (IOException e) { // network, DNS, TLS, timeout
throw new UncheckedIOException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // always restore the flag
throw new IllegalStateException("interrupted", e);
}
}
}
Note the InterruptedException handling. Catching it clears the thread's interrupt
flag, so re-setting it with Thread.currentThread().interrupt() is not optional — without
it, code further up the stack cannot tell that cancellation was requested.
Compared with what it replaced
The gap is worth seeing, because it explains why so many projects carried an HTTP dependency for
one or two calls. The HttpURLConnection version of the four-line GET at the top of this
post is roughly this:
class Legacy {
String get(String url) throws IOException {
HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(10_000);
connection.setReadTimeout(30_000);
try {
StringBuilder body = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
body.append(line).append('\n');
}
}
return body.toString();
} finally {
connection.disconnect();
}
}
}
Manual stream handling, a charset that is easy to omit, mutable configuration on the connection
object itself, and a separate getErrorStream() path for non-2xx responses that this
version does not even handle. It is also HTTP/1.1 only and synchronous only.
The new client is HTTP/2 by default with automatic fallback, gives you async for free, and is
immutable so a single configured client is safe to share across threads. If you are maintaining code
that still uses HttpURLConnection, this is one of the easier and more worthwhile
modernisations available.
What it does not do
It has no JSON support. It hands you a String and you parse it yourself with Jackson
or Gson. That is a deliberate scope decision, not an oversight, and it means the client is useful
without dragging a serialisation library into the JDK.
It also has no built-in retry, no circuit breaker and no request logging. For a handful of calls that is fine. For a service that talks to many others, a higher-level client still earns its keep — but you no longer need one just to make a request.
Next
Running Java files directly is next — the change that makes Java usable for a quick script.