HTTP, REST, and JSON in Java
Executive Summary
HTTP, REST, and JSON in Java start with java.net.http.HttpClient, standard since Java 11. It builds an immutable client with Builder.newBuilder. Then send() makes a blocking call, while sendAsync() makes a CompletableFuture-based one. HttpRequest.newBuilder() constructs the request: a URI, a method (GET by default; POST and PUT need a BodyPublisher), and headers. HttpResponse pairs with a BodyHandler, ofString() for text, ofInputStream() for streaming, and exposes statusCode(), headers(), and body().
JSON itself has no JDK API. So Jackson’s ObjectMapper (readValue and writeValueAsString) is the practical standard for turning JSON into records and back. It also offers Optional-friendly null handling and a configuration object you tune once per application.
However, three pitfalls bite in production. First, a forgotten timeout hangs a thread forever. Second, code ignores non-2xx status codes because the call did not throw. Finally, teams map JSON into mutable classes when a record is the better, safer target. Deep coverage of Spring’s RestTemplate or WebClient over this same HTTP layer belongs in the Spring Boot course on this site. Instead, this article stays entirely on the JDK and Jackson.
HttpClient: One Object, Reused
An HttpClient is expensive to build and cheap to reuse, so construct one per application, not one per request:
import java.net.http.HttpClient;
import java.time.Duration;
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) // fail fast on connect, not hang forever
.version(HttpClient.Version.HTTP_2) // HTTP/2 when the server supports it
.build();
The client is immutable and thread-safe, so a single instance serves every request your service makes, in sequence or concurrently. Skipping connectTimeout is, in my experience, the most common cause of a thread pool quietly filling up. A slow or dead upstream holds a connection open. Without a bound, the calling thread then waits indefinitely. The default, if you omit it, has no connect timeout at all.
HttpRequest: Method, URI, Body
A GET request needs only a URI, however a POST needs a BodyPublisher carrying the payload:
import java.net.URI;
import java.net.http.HttpRequest;
HttpRequest getRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/42"))
.timeout(Duration.ofSeconds(10)) // per-request timeout, separate from connect
.header("Accept", "application/json")
.GET() // GET is the default, explicit here for clarity
.build();
String json = "{\"name\":\"Ada\",\"role\":\"engineer\"}";
HttpRequest postRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
Notice that the two timeouts cover different concerns. The connectTimeout on the client bounds the TCP handshake. Meanwhile, timeout() on the request bounds the whole round trip. It even covers a slow server that accepted the connection but never answers. Set both, because a server that accepts connections instantly but never responds defeats connectTimeout entirely.
HttpResponse: Status, Headers, Body
send() blocks the calling thread and returns a fully populated response. Otherwise, it throws an IOException, where the exceptions article’s checked-exception discipline applies directly:
import java.net.http.HttpResponse;
import java.io.IOException;
try {
HttpResponse<String> response = client.send(getRequest, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
System.out.println(response.body());
} else {
System.out.println("Unexpected status: " + response.statusCode()); // not every failure throws
}
} catch (IOException | InterruptedException e) {
throw new RuntimeException("Request to example.com failed", e);
}
The trade-off here is the one beginners miss every time: send() does not throw on a 404 or a 500. HTTP-level failure is a status code, not an exception, so checking statusCode() is mandatory, not optional defensive code. For example, I once saw a payment integration that logged “success” on every call because nobody checked for 402. As a result, the bug surfaced only when finance reconciled the ledger a week later.
Async Calls: sendAsync and CompletableFuture
sendAsync() returns a CompletableFuture immediately, so the calling thread moves on while the request runs in the background. This is the exact composition style the CompletableFuture article covers in depth:
CompletableFuture<HttpResponse<String>> future =
client.sendAsync(getRequest, HttpResponse.BodyHandlers.ofString());
future.thenApply(HttpResponse::body)
.thenAccept(body -> System.out.println("Got: " + body))
.exceptionally(ex -> {
System.out.println("Call failed: " + ex.getMessage());
return null;
});
This is where HttpClient and the concurrency part of the course meet directly. So a service firing ten outbound calls to enrich one response should use sendAsync and combine the futures. It should not block ten times in sequence because sequential blocking calls turn a 50ms operation into a 500ms one.
JSON with Jackson: Records In, Records Out
The JDK has no built-in JSON parser, so Jackson’s ObjectMapper is the practical default for HTTP, REST, and JSON in Java. A lighter alternative, org.json, suits simple cases. However, Jackson’s record support makes it the better fit for this course. A record from the records article maps cleanly, because Jackson can use its canonical constructor directly:
import com.fasterxml.jackson.databind.ObjectMapper;
public record User(String name, String role) {}
ObjectMapper mapper = new ObjectMapper();
// JSON text to a record:
User user = mapper.readValue(response.body(), User.class);
System.out.println(user.name() + " is a " + user.role());
// a record back to JSON text:
String outbound = mapper.writeValueAsString(new User("Grace", "architect"));
readValue() throws a checked JsonProcessingException, so wrap it per the exceptions article’s rule. In short, catch it at the boundary and translate it into a meaningful domain exception. Never swallow it silently. Jackson needs the jackson-databind dependency, added through Maven or Gradle per the build-tooling articles. For records specifically, it also works out of the box on Java 21 without extra modules.
// WRONG: silently losing information
try {
User user = mapper.readValue(badJson, User.class);
} catch (Exception e) {
// nothing here: the caller gets a null user and no idea why
}
// RIGHT: fail loudly with context
try {
User user = mapper.readValue(badJson, User.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Malformed user JSON from upstream: " + badJson, e);
}
The delta matters because a swallowed parse failure turns into a null user three call frames away. Then whoever debugs it has lost the original JSON entirely.
Putting HTTP, REST, and JSON in Java Together
public class UserClient {
private final HttpClient client;
private final ObjectMapper mapper;
private final URI baseUri;
public UserClient(URI baseUri) {
this.client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
this.mapper = new ObjectMapper();
this.baseUri = baseUri;
}
public User fetchUser(String id) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(baseUri.resolve("/users/" + id))
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("User fetch failed: HTTP " + response.statusCode());
}
return mapper.readValue(response.body(), User.class);
}
}
This class is the shape every REST-consuming service in this course will reuse. It holds one HttpClient and one ObjectMapper, both built once. It also has a method per endpoint that returns a domain record. It is also the shape the dependency injection article hands to a constructor instead of newing up inside a service.
HttpClient vs Spring’s RestTemplate and WebClient
| Aspect | java.net.http.HttpClient | Spring RestTemplate / WebClient |
|---|---|---|
| Where it lives | JDK standard library, Java 11+ | Spring Framework, a dependency you add |
| Async model | CompletableFuture via sendAsync | WebClient: Reactor Mono/Flux; RestTemplate: blocking only |
| JSON handling | Manual, via Jackson or org.json | Automatic, via HttpMessageConverters |
| Status in this course | Taught here, Part 9 | Deep coverage reserved for the Spring Boot course |
| Best fit | Any plain Java app, libraries, CLIs | Spring-managed services with DI already wired |
Deep coverage of RestTemplate and WebClient belongs in the Spring Boot course on this site. What you learn here, HttpClient, HttpRequest, HttpResponse, and Jackson, is the foundation those Spring abstractions wrap. As a result, none of it goes to waste once you reach that course.
How Real Systems Do This
Production services almost never call HttpClient directly from business logic. Instead, they wrap it in a thin client class, the UserClient shape above, and inject it where a repository would go. They also centralize timeout and retry policy in one place instead of scattering .timeout() calls across the codebase. Retries, when added, use exponential backoff and only retry idempotent methods (GET, not POST) to avoid double-submitting a payment.
For example, we once hit a bottleneck with a downstream pricing service. It stayed healthy 99 percent of the time but occasionally hung for thirty seconds under load. The calling service had only a connect timeout and no per-request timeout. So threads piled up waiting on bodies that never finished. Then we added an explicit HttpRequest timeout of 2 seconds and a circuit breaker around repeated failures. After that, the thread pool stopped starving, and a single downstream blip stopped cascading into a full outage. The fix cost one line of code; diagnosing it cost an afternoon of thread dumps.
Decision Framework
- Is this a plain Java service, library, or CLI? Use java.net.http.HttpClient directly, no extra dependency needed for the transport layer.
- Does the call need to run without blocking a thread? Use sendAsync() and compose with CompletableFuture, per the concurrency part of this course.
- Is the payload JSON? Add Jackson’s databind module and map to records, not mutable classes, so parsed data is immutable and self-documenting.
- Could the upstream be slow or unresponsive? Set both connectTimeout and a per-request timeout, always, with no exceptions.
- Is this code running inside a Spring-managed application? Still prototype and understand it with HttpClient first; the Spring Boot course layers RestTemplate or WebClient on the same concepts.
- Will you retry the call? Confirm the HTTP method is idempotent before adding retry logic, or a flaky network turns one POST into three.
When NOT to Use This
- Do not reach for java.net.http inside a Spring Boot application that already standardizes on WebClient. Mixing clients fragments timeout and logging configuration across the codebase.
- Do not use the blocking send() method inside a request-handling thread pool under high concurrency without a bounded timeout. Otherwise, one slow upstream call can starve the pool, exactly the bottleneck from the story above.
- Do not hand-roll JSON parsing with String splitting or regular expressions once a payload has nested objects or arrays. After all, Jackson’s ObjectMapper exists precisely so you never do this.
Common Mistakes
- Building a new HttpClient per request: it is expensive to construct and built for sharing. So reusing one instance per application is the correct lifecycle.
- Skipping connectTimeout and the per-request timeout: an unresponsive upstream then hangs the calling thread indefinitely. Worse, it leaves no error and no log line.
- Assuming send() throws on a bad status code: it does not. So check statusCode() explicitly on every call that matters.
- Mapping JSON into mutable classes with public setters: records give you immutability and a constructor Jackson can bind to directly. They also mean less code and fewer bugs.
- Swallowing JsonProcessingException in an empty catch block: you lose the original malformed JSON the moment the catch block ignores it.
- Retrying a POST blindly on timeout: the first request may have succeeded server-side. In that case, a retry can create a duplicate resource or a double charge.
Key Takeaways
- java.net.http.HttpClient, standard since JEP 321 and Java 11, removes the need for third-party HTTP libraries.
- Build one HttpClient per application, reuse it, and set both connectTimeout and a per-request timeout on every call.
- HttpRequest.newBuilder() constructs the method, URI, headers, and body; HttpResponse exposes statusCode(), headers(), and body().
- Non-2xx responses do not throw exceptions; checking statusCode() is mandatory, not optional.
- sendAsync() returns a CompletableFuture, letting outbound calls run concurrently instead of blocking in sequence.
- Jackson’s ObjectMapper maps JSON straight into records for input and output, keeping parsed data immutable.
- RestTemplate and WebClient wrap these same concepts inside Spring. However, deep coverage of them belongs in the Spring Boot course on this site.
FAQ
Is java.net.http.HttpClient part of the JDK?
Yes. It shipped as a standard feature in Java 11 under JEP 321, replacing the older, more awkward HttpURLConnection. So modern Java needs no external dependency to send HTTP requests.
Does Java have a built-in JSON library?
No. The JDK has no bundled JSON parser, so most Java projects add Jackson (jackson-databind) or org.json. Jackson is the de facto standard because of its direct support for records and broad ecosystem adoption.
Should I use RestTemplate or java.net.http.HttpClient?
In a plain Java course and in any non-Spring code, use java.net.http.HttpClient. It needs no extra dependency, and the JDK fully supports it. RestTemplate (and its replacement, WebClient) belong to the Spring ecosystem. So deep coverage of them waits for the Spring Boot course on this site.
Why doesn’t HttpClient throw an exception on a 404 response?
HTTP treats status codes as part of a normal response, not as a transport failure. In fact, send() only throws for actual I/O problems (IOException) or interruption. So checking response.statusCode() is how your code detects a 404, 500, or other non-2xx result.
Why map JSON to a record instead of a regular class?
A record gives Jackson a canonical constructor to bind JSON fields to directly. Afterward, it has no setters and no mutable state. It also matches the immutability discipline this course already recommends for any data carrier. Besides, it needs less boilerplate than a class with getters and a constructor.
Conclusion
You now have the complete loop for HTTP, REST, and JSON in Java. It includes a reusable HttpClient and HttpRequest objects built with the fluent API. It also includes HttpResponse handling that checks status codes honestly, plus Jackson turning JSON into records and back. The next article moves one layer up to dependency injection as a design principle. As a result, the UserClient above will arrive through a constructor instead of inline construction.
Build one client, reuse it everywhere, and always set a timeout. That rule alone prevents most of the HTTP incidents you will ever page for.
Last updated on 10 September 2026.
