The Java HTTP Client
Since Java 11 the platform includes a modern HTTP client with synchronous and asynchronous requests and HTTP/2 support.
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
Why it was added
The old HttpURLConnection was awkward, synchronous only and limited to HTTP/1.1, which is why almost every project added a third party client. Java 11 made a proper one part of the platform.
Creating a client
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // falls back to 1.1 automatically
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();An HttpClient is immutable, thread safe and holds a connection pool. Create one and reuse it. Building a new client per request throws away pooling and is a common performance mistake.A GET request
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/notes"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.headers().firstValue("content-type").orElse("unknown"));
System.out.println(response.body());POST with a body
String json = """
{"title": "Java HTTP client", "published": true}""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/notes"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();| Body publisher | Sends |
|---|---|
ofString(text) | Text, UTF-8 by default |
ofByteArray(bytes) | Raw bytes |
ofFile(path) | A file, streamed |
ofInputStream(supplier) | A stream |
noBody() | Nothing |
Handling the response
HttpResponse<String> text = client.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<byte[]> bytes = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
HttpResponse<Path> file = client.send(request,
HttpResponse.BodyHandlers.ofFile(Path.of("download.zip")));
HttpResponse<Stream<String>> lines = client.send(request,
HttpResponse.BodyHandlers.ofLines());
HttpResponse<Void> discarded = client.send(request, HttpResponse.BodyHandlers.discarding());if (response.statusCode() >= 200 && response.statusCode() < 300) {
process(response.body());
} else {
throw new IOException("Request failed with " + response.statusCode());
}The client does not throw on a 404 or a 500. Those are valid HTTP responses, so the status code must be checked explicitly.
Asynchronous requests
CompletableFuture<String> future = client
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.exceptionally(error -> {
logger.warning("Request failed: " + error.getMessage());
return "";
});
String body = future.join();List<CompletableFuture<String>> requests = urls.stream()
.map(url -> HttpRequest.newBuilder().uri(URI.create(url)).build())
.map(req -> client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body))
.toList();
CompletableFuture.allOf(requests.toArray(new CompletableFuture[0])).join();
List<String> bodies = requests.stream().map(CompletableFuture::join).toList();The asynchronous form returns a CompletableFuture, so many requests run concurrently on the internal executor without a thread each.
Timeouts, at two levels
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) // establishing the connection
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofSeconds(30)) // the whole exchange
.build();Exceeding the request timeout completes the future exceptionally with an HttpTimeoutException. Set both; a connect timeout alone does not protect against a server that accepts the connection and then stalls.
Authentication and headers
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("Authorization", "Bearer " + token)
.header("User-Agent", "notes-client/1.0")
.headers("X-Request-Id", requestId, "Accept", "application/json")
.build();// Never log a header that may carry a credential
logger.fine("Calling " + request.uri());Form data
Map<String, String> form = Map.of("title", "Java HTTP", "status", "draft");
String encoded = form.entrySet().stream()
.map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
+ "=" + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(encoded))
.build();Retrying sensibly
public HttpResponse<String> sendWithRetry(HttpRequest request, int attempts)
throws IOException, InterruptedException {
IOException last = null;
for (int attempt = 1; attempt <= attempts; attempt++) {
try {
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 500) {
return response; // do not retry a client error
}
} catch (HttpTimeoutException | ConnectException e) {
last = e;
}
Thread.sleep(200L * attempt); // simple back off
}
throw last != null ? last : new IOException("All attempts failed");
}Retry only what is safe to repeat. A GET is naturally idempotent; a POST that creates a record is not, unless the server supports an idempotency key.
A complete example
public class NoteApiClient {
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final URI baseUri;
public NoteApiClient(URI baseUri) {
this.baseUri = baseUri;
}
public String fetchNote(long id) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(baseUri.resolve("/api/notes/" + id))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.GET()
.build();
HttpResponse<String> response =
CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 404) {
throw new NoSuchElementException("No note with id " + id);
}
if (response.statusCode() != 200) {
throw new IOException("Unexpected status " + response.statusCode());
}
return response.body();
}
}What it does not include
There is no JSON parsing in the platform. The client hands you a String or bytes, and mapping to objects needs a library. Keep that mention brief: the client itself is the standard part.
Common mistakes
- Creating a new
HttpClientfor every request. - Assuming a non 2xx status throws.
- Setting a connect timeout but no request timeout.
- Retrying a non idempotent request.
- Concatenating unencoded values into a query string.
- Logging authorisation headers.
Best practices
- Create one client and share it.
- Set both timeouts on every call.
- Check the status code explicitly and map it to meaningful exceptions.
- Use the asynchronous API when several independent calls can overlap.
- Encode query and form values.
- Stream large downloads to a file rather than into a
String.
Practice
- Fetch a public endpoint and print the status, content type and first line of the body.
- Send three requests concurrently and collect the results.
- Explain why a 404 does not throw, and write the handling it needs.
- Add a retry that backs off and never retries a client error.
- Download a file directly to disk without holding it in memory.
Conclusion
The standard HTTP client covers most needs without a dependency. Share one instance, set both timeouts, check status codes yourself, and use the asynchronous form when calls can overlap.