Java’s JDK can both call and serve HTTP with no libraries. Learn HttpClient, sync and async, then build a small JSON tasks service on the built-in HttpServer with virtual threads, correct status codes and a clean shutdown.
The JDK ships both halves of HTTP. java.net.http.HttpClient sends requests, and com.sun.net.httpserver.HttpServer answers them. Neither needs a library, and together they’re enough for a small internal service, a fake server in a test, or a tool that calls an API.
This post covers the client, async requests, timeouts, redirects and the server’s rules for responses, then builds a small tasks service as a real module. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java. The machine that checks these programs has no internet access, so each one starts its own server on port 0 and calls that.
A server on port 0, and a client to call it
An HttpServer listens on a port and passes each request to a handler registered for a path. Port 0 asks the operating system for any free port, which is what examples and tests want. This program starts a server with one handler, calls it four times and stops it:
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
// Port 0 asks the operating system for any free port.
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/hello", exchange -> {
String text = "hello, you asked for " + exchange.getRequestURI().getPath() + "\n";
byte[] body = text.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
int port = server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
for (String path : List.of("/hello", "/hello/world", "/helloworld", "/")) {
var uri = URI.create("http://localhost:" + port + path);
var response = client.send(HttpRequest.newBuilder(uri).build(),
HttpResponse.BodyHandlers.ofString());
IO.println(response.statusCode() + " " + path + ": " + response.body().strip());
}
}
server.stop(0);
}
It prints:
200 /hello: hello, you asked for /hello
200 /hello/world: hello, you asked for /hello/world
200 /helloworld: hello, you asked for /helloworld
404 /: <h1>404 Not Found</h1>No context found for request
The handler receives an HttpExchange, which holds the request and the response. It sets a header, calls sendResponseHeaders with the status and the body’s length in bytes, then writes the body. getAddress().getPort() says which port the server got, and stop(0) stops it without waiting.
Look at /helloworld. A context matches any path that starts with its string, so /hello matched it. A path with no context gets the JDK’s own HTML 404. A handler that cares about exact paths has to check them itself.
The imports aren’t optional. A compact source file imports java.base for you, but java.net.http and jdk.httpserver are separate modules.
HttpClient: build a request, send it, read the response
An HttpClient sends HttpRequest objects and returns HttpResponse objects. Build one and reuse it, because it keeps a pool of open connections. HttpClient became a standard API in Java 11, and javac --release 10 can’t find the java.net.http package.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/echo", exchange -> {
String sent = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
String text = exchange.getRequestMethod() + " with "
+ exchange.getRequestHeaders().getFirst("Content-Type") + ": " + sent;
byte[] body = text.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("X-Echo-Length", String.valueOf(sent.length()));
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
URI echo = URI.create("http://localhost:" + server.getAddress().getPort() + "/echo");
try (HttpClient client = HttpClient.newHttpClient()) {
HttpRequest request = HttpRequest.newBuilder(echo)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"title\":\"Buy milk\"}"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
IO.println("status: " + response.statusCode());
IO.println("body: " + response.body());
IO.println("header: " + response.headers().firstValue("X-Echo-Length").orElse("none"));
IO.println("asked: " + client.version());
IO.println("got: " + response.version());
}
server.stop(0);
}
It prints:
status: 200
body: POST with application/json: {"title":"Buy milk"}
header: 20
asked: HTTP_2
got: HTTP_1_1
send blocks until the response arrives. The body handler decides what the body becomes: ofString() here, or ofByteArray(), ofFile(path) and discarding(). headers().firstValue returns an Optional<String> and ignores the case of the name.
The last two lines matter later. The client prefers HTTP/2, but the JDK’s server answered with HTTP/1.1, and the client quietly used that. The try block closes the client, which HttpClient has allowed since Java 21.
sendAsync returns a CompletableFuture
sendAsync starts the request and returns at once, with a CompletableFuture that completes when the response arrives. The part on java.util.concurrent covers CompletableFuture itself.
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/square", exchange -> {
int n = Integer.parseInt(exchange.getRequestURI().getQuery().substring("n=".length()));
byte[] body = String.valueOf(n * n).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
String base = "http://localhost:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
List<CompletableFuture<String>> futures = new ArrayList<>();
for (int n = 1; n <= 5; n++) {
var request = HttpRequest.newBuilder(URI.create(base + "/square?n=" + n)).build();
CompletableFuture<String> future = client
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body);
futures.add(future);
}
IO.println("sent " + futures.size() + " requests, none of them waited for another");
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
IO.println(futures.stream().map(CompletableFuture::join).toList());
}
server.stop(0);
}
It prints:
sent 5 requests, none of them waited for another
[1, 4, 9, 16, 25]
The loop starts five requests without waiting for any of them. thenApply(HttpResponse::body) turns each future of a response into a future of its body. CompletableFuture.allOf waits for all five, and after that each join returns immediately. The results come out in the order the futures went into the list, not the order the responses arrived.
The server runs each request on a virtual thread, set with setExecutor. The service below explains why.
Timeouts and redirects
A client without a timeout can wait forever for a server that never answers. HttpClient has two timeouts: connectTimeout on the client, for opening a connection, and timeout on each request, for waiting for the response. Following redirects is one line on the same builder:
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
void reply(HttpExchange exchange, int status, String text) throws IOException {
byte[] body = text.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, body.length);
try (var out = exchange.getResponseBody()) {
out.write(body);
}
}
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/old", exchange -> {
exchange.getResponseHeaders().set("Location", "/new");
reply(exchange, 302, "moved");
});
server.createContext("/new", exchange -> reply(exchange, 200, "the new page"));
server.createContext("/slow", exchange -> {
try {
Thread.sleep(Duration.ofSeconds(2));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
reply(exchange, 200, "finally");
});
server.start();
String base = "http://localhost:" + server.getAddress().getPort();
try (HttpClient plain = HttpClient.newHttpClient();
HttpClient following = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.followRedirects(HttpClient.Redirect.NORMAL)
.build()) {
var old = HttpRequest.newBuilder(URI.create(base + "/old")).build();
var r1 = plain.send(old, HttpResponse.BodyHandlers.ofString());
IO.println("plain: " + r1.statusCode() + " " + r1.body()
+ ", Location " + r1.headers().firstValue("Location").orElse("none"));
var r2 = following.send(old, HttpResponse.BodyHandlers.ofString());
IO.println("following: " + r2.statusCode() + " " + r2.body()
+ ", from " + r2.uri().getPath());
var slow = HttpRequest.newBuilder(URI.create(base + "/slow"))
.timeout(Duration.ofMillis(200))
.build();
try {
following.send(slow, HttpResponse.BodyHandlers.ofString());
} catch (HttpTimeoutException e) {
IO.println("slow: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
server.stop(0);
}
It prints:
plain: 302 moved, Location /new
following: 200 the new page, from /new
slow: HttpTimeoutException: request timed out
The default redirect policy is Redirect.NEVER, so the plain client returned the 302 and its Location header. Redirect.NORMAL follows redirects, except from an HTTPS URL to an HTTP one, and response.uri() shows where the final response came from.
The slow request threw HttpTimeoutException after 200 milliseconds, while the handler on the server was still asleep. It’s a subclass of IOException.
The project: a tasks service in one module
The rest of this post builds a small service that keeps a task list in memory and serves it as JSON. It’s a named module, com.example.tasks, with no build tool and no library. It answers four requests:
| Request | What it does | Success | Client errors |
|---|---|---|---|
GET /tasks |
list every task, sorted by id | 200 | |
POST /tasks |
create a task | 201 with Location |
400, 413, 415, 422 |
GET /tasks/{id} |
fetch one task | 200 | 404 |
DELETE /tasks/{id} |
delete one task | 204 | 404 |
A known path with the wrong method gets 405 and an Allow header. Any other path gets 404, and a bug in the code gets 500. Every error has the same body shape, {"error":"..."}. Here is the whole project, apart from its .gitignore:
19-tasks-service/
├── run-checks.sh
├── checks/
│ └── SmokeCheck.java
└── src/
└── com.example.tasks/
├── module-info.java
└── com/example/tasks/
├── Main.java
├── http/
│ ├── Json.java
│ ├── TaskHandler.java
│ └── TaskServer.java
└── store/
├── InMemoryTaskStore.java
├── Task.java
└── TaskStore.java
The module needs the JDK’s HTTP server module and exports two packages:
/** A small task list over HTTP, built on the JDK's own HTTP server. */
module com.example.tasks {
requires jdk.httpserver;
exports com.example.tasks.http;
exports com.example.tasks.store;
}
Because http and store are exported, other code, such as a test, can start the server with any store. The part on testing and shipping builds a small runtime from this module with jlink.
The store: an interface in front of a map
A task is record Task(long id, String title, boolean done), and the HTTP code reaches tasks only through an interface. That way a test, or a later database version, can pass in a different store:
package com.example.tasks.store;
import java.util.List;
import java.util.Optional;
/** Everything the HTTP layer needs from storage. Implementations must be thread-safe. */
public interface TaskStore {
/** Returns every task, sorted by id. */
List<Task> list();
/** Returns the task with this id, or an empty Optional. */
Optional<Task> get(long id);
/** Stores a new task under the next id and returns it. */
Task create(String title, boolean done);
/** Deletes the task with this id. Returns false if there was none. */
boolean delete(long id);
}
The in-memory version uses a ConcurrentHashMap for the tasks and an AtomicLong for the ids:
/** Keeps tasks in memory. Safe for many requests at once; lost on restart. */
public final class InMemoryTaskStore implements TaskStore {
private final ConcurrentHashMap<Long, Task> tasks = new ConcurrentHashMap<>();
private final AtomicLong lastId = new AtomicLong();
/** Creates an empty store. The first task gets id 1. */
public InMemoryTaskStore() {
}
@Override
public List<Task> list() {
return tasks.values().stream()
.sorted(Comparator.comparingLong(Task::id))
.toList();
}
@Override
public Optional<Task> get(long id) {
return Optional.ofNullable(tasks.get(id));
}
@Override
public Task create(String title, boolean done) {
var task = new Task(lastId.incrementAndGet(), title, done);
tasks.put(task.id(), task);
return task;
}
@Override
public boolean delete(long id) {
return tasks.remove(id) != null;
}
}
Requests run at the same time, so two POST requests can reach create together. incrementAndGet still gives each one a different id, with no lock. list sorts by id, because a hash map has no useful order. A list running during a create may or may not include the new task, since iterating a ConcurrentHashMap doesn’t freeze it.
A virtual thread per request
TaskServer builds the server. It takes its dependencies, a store and a port, as arguments, so a test can start one on port 0 with a fresh store:
/**
* Starts serving the store on the given port, on every local address.
* Port 0 asks the operating system for a free port; port() says which one it got.
*/
public static TaskServer start(TaskStore store, int port) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
server.setExecutor(executor);
server.createContext("/", new TaskHandler(store));
server.start();
return new TaskServer(server, executor);
}
One context at / sends every request to one handler. The setExecutor line decides which thread runs each request. Without it, the JDK runs every handler on the server’s single dispatcher thread, one request at a time. This program sends five requests at once to a handler that waits 300 milliseconds, first with no executor and then with virtual threads:
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.atomic.AtomicInteger;
/** Sends 5 requests at once. Each handler waits 300 ms, like a slow database call. */
void run(String label, Executor executor) throws Exception {
var inFlight = new AtomicInteger();
var mostAtOnce = new AtomicInteger();
var virtual = new AtomicInteger();
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.setExecutor(executor);
server.createContext("/work", exchange -> {
mostAtOnce.accumulateAndGet(inFlight.incrementAndGet(), Math::max);
if (Thread.currentThread().isVirtual()) {
virtual.incrementAndGet();
}
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
inFlight.decrementAndGet();
exchange.sendResponseHeaders(204, -1);
exchange.close();
});
server.start();
var uri = URI.create("http://localhost:" + server.getAddress().getPort() + "/work");
try (HttpClient client = HttpClient.newHttpClient()) {
var futures = new ArrayList<CompletableFuture<HttpResponse<Void>>>();
for (int i = 0; i < 5; i++) {
futures.add(client.sendAsync(HttpRequest.newBuilder(uri).build(),
HttpResponse.BodyHandlers.discarding()));
}
futures.forEach(CompletableFuture::join);
}
server.stop(0);
IO.println(label + ": at most " + mostAtOnce.get() + " at once, "
+ virtual.get() + " of 5 on virtual threads");
}
void main() throws Exception {
run("no executor ", null);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
run("virtual threads", executor);
}
}
It prints:
no executor : at most 1 at once, 0 of 5 on virtual threads
virtual threads: at most 5 at once, 5 of 5 on virtual threads
With no executor, the requests queued behind each other. With Executors.newVirtualThreadPerTaskExecutor(), all five handlers were waiting at the same moment, each on its own virtual thread. Virtual threads became final in Java 21, and their own part covers how they work.
Explain it like I’m ten
Think of the server as a shop with one counter. One person stands at the door and notices each customer who comes in.
Without an executor, that same person also serves every customer. If one customer asks for something from the stockroom, everyone behind them waits until it comes back.
With virtual threads, every customer gets their own helper as soon as they walk in. Helpers cost almost nothing, so the shop can have thousands. While a helper waits for the stockroom, they step away from the counter, and another helper uses it.
The precise version
HttpServer has one dispatcher thread that watches all connections. When a request is ready, the dispatcher hands a task to the executor, and that task reads the request and calls your handler. newVirtualThreadPerTaskExecutor() starts a new virtual thread for every task. When the handler blocks, for example in Thread.sleep or on a socket read, its virtual thread gives up the platform thread it was running on, called its carrier. There are only a few carriers, about one per CPU core by default, and they’re free to run other virtual threads.
TaskServer.stop closes the executor after server.stop, because HttpServer never closes an executor you gave it.
Where the analogy breaks: helpers don’t make the stockroom any bigger. If ten thousand requests all wait for a database that allows ten connections, 9,990 virtual threads wait in line there. Virtual threads also don’t speed up work that uses the CPU the whole time, because that work needs a carrier for as long as it runs. They help when requests spend most of their time waiting.
Routing by method and path inside the handler
The handler turns a method and a path into a call on the store. The JDK has no router, so it’s plain if and switch:
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
try {
route(exchange);
} catch (RequestException e) {
sendError(exchange, e.status, e.getMessage());
} catch (RuntimeException e) {
LOG.log(Level.ERROR, "request failed: " + exchange.getRequestURI(), e);
if (exchange.getResponseCode() == -1) {
sendError(exchange, 500, "internal server error");
}
}
}
}
private void route(HttpExchange exchange) throws IOException, RequestException {
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
if (path.equals("/tasks")) {
switch (method) {
case "GET" -> sendJson(exchange, 200, Json.tasks(store.list()));
case "POST" -> createTask(exchange);
default -> throw methodNotAllowed(exchange, "GET, POST");
}
} else if (path.startsWith("/tasks/")) {
String rawId = path.substring("/tasks/".length());
switch (method) {
case "GET" -> getTask(exchange, parseId(rawId));
case "DELETE" -> deleteTask(exchange, parseId(rawId));
default -> throw methodNotAllowed(exchange, "GET, DELETE");
}
} else {
throw new RequestException(404, "not found");
}
}
HttpExchange is AutoCloseable, so the outer try always ends the exchange. The inner one turns a RequestException into an error response. Any other RuntimeException is a bug: it’s logged, and the client gets a 500 if no status has gone out yet, which is what getResponseCode() == -1 means. Without that catch, the JDK just closes the connection, and the client gets no status at all.
route compares the whole path, so /tasksfoo is a 404. A wrong method gets a 405 with the Allow header that HTTP requires on it:
private static RequestException methodNotAllowed(HttpExchange exchange, String allow) {
exchange.getResponseHeaders().set("Allow", allow);
return new RequestException(405, "method not allowed");
}
/** Only a positive decimal number names a task: "7" does; "+7", "07x" and "-3" don't. */
private static long parseId(String raw) throws RequestException {
boolean digits = !raw.isEmpty() && raw.length() <= 18
&& raw.chars().allMatch(c -> c >= '0' && c <= '9');
long id = digits ? Long.parseLong(raw) : 0;
if (id <= 0) {
throw new RequestException(404, "task not found");
}
return id;
}
The digit check comes before Long.parseLong, because parseLong accepts "+7". /tasks/abc, /tasks/0 and /tasks/+7 all answer 404. RequestException is a checked exception that carries a status:
/** A problem with the request that the client can fix. */
static final class RequestException extends Exception {
@Serial
private static final long serialVersionUID = 1L;
final int status;
RequestException(int status, String message) {
super(message);
this.status = status;
}
}
Reading a request body with a size limit
A request body comes from whoever sent the request, so the service checks its type and caps its size before parsing it:
private void createTask(HttpExchange exchange) throws IOException, RequestException {
requireJson(exchange);
NewTask input = parseNewTask(readBody(exchange));
Task task = store.create(input.title(), input.done());
exchange.getResponseHeaders().set("Location", "/tasks/" + task.id());
sendJson(exchange, 201, Json.task(task));
}
private static void requireJson(HttpExchange exchange) throws RequestException {
String type = exchange.getRequestHeaders().getFirst("Content-Type");
String mediaType = type == null ? "" : type.split(";", 2)[0].strip();
if (!mediaType.equalsIgnoreCase("application/json")) {
throw new RequestException(415, "Content-Type must be application/json");
}
}
/** Reads the whole body as UTF-8, but never more than MAX_BODY_BYTES + 1 bytes of it. */
private static String readBody(HttpExchange exchange) throws IOException, RequestException {
byte[] bytes;
try (InputStream in = exchange.getRequestBody()) {
bytes = in.readNBytes(MAX_BODY_BYTES + 1);
}
if (bytes.length > MAX_BODY_BYTES) {
throw new RequestException(413,
"request body must not be larger than " + MAX_BODY_BYTES + " bytes");
}
try {
return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
throw new RequestException(400, "request body must be UTF-8");
}
}
requireJson answers 415 Unsupported Media Type when the Content-Type isn’t application/json. The body might be fine, and the status says the label is what’s wrong.
readNBytes(MAX_BODY_BYTES + 1) never reads more than 16,385 bytes, and that one extra byte tells “too big” from “exactly at the limit”. Past the limit the answer is 413 Content Too Large. The code counts bytes instead of trusting Content-Length, which a client can get wrong or leave out.
The decoder from newDecoder() reports bad UTF-8 as an error. new String(bytes, UTF_8) would quietly replace bad bytes with � instead.
JSON without a library
The JDK has no JSON API, and on Java 25 there’s no module for it:
$ java --list-modules | grep -c .
69
$ java --list-modules | grep -ci json
0
Real services use a library such as Jackson or Gson. This project is JDK-only, so it writes JSON by hand and reads a small, strict subset. Writing is the easy half:
static String task(Task task) {
return "{\"id\":" + task.id()
+ ",\"title\":" + string(task.title())
+ ",\"done\":" + task.done() + "}";
}
static String tasks(List<Task> tasks) {
return tasks.stream().map(Json::task).collect(Collectors.joining(",", "[", "]"));
}
static String error(String message) {
return "{\"error\":" + string(message) + "}";
}
/** Quotes a string, escaping what JSON requires: quote, backslash, control characters. */
static String string(String s) {
var out = new StringBuilder(s.length() + 2).append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> {
if (c < 0x20) {
out.append(String.format("\\u%04x", (int) c));
} else {
out.append(c);
}
}
}
}
return out.append('"').toString();
}
JSON requires escapes for a quote, a backslash and the control characters. Everything else can go out as it is.
Reading was the harder decision. One honest option was to accept a text/plain title and not parse JSON at all. But clients of a JSON API send JSON, so the service accepts it and says exactly how much:
/**
* Parses one flat JSON object whose values are strings, true or false.
* Numbers, null, arrays, nested objects and repeated names are rejected
* on purpose: this service never needs them.
*/
static Map<String, Object> parseObject(String text) {
return new Parser(text).object();
}
The parser reads one flat object whose values are strings, true or false. Anything else gets a 400 with a message, never a guess. That covers what a task needs. The day the service needs more is the day to add a library, not to grow this parser.
parseNewTask then checks the fields:
private static NewTask parseNewTask(String body) throws RequestException {
Map<String, Object> fields;
try {
fields = Json.parseObject(body);
} catch (Json.BadJson e) {
throw new RequestException(400, e.getMessage());
}
for (String name : fields.keySet()) {
if (!name.equals("title") && !name.equals("done")) {
throw new RequestException(400, "unknown field " + Json.string(name));
}
}
if (!(fields.getOrDefault("title", "") instanceof String rawTitle)) {
throw new RequestException(400, "field \"title\" must be a string");
}
if (!(fields.getOrDefault("done", false) instanceof Boolean done)) {
throw new RequestException(400, "field \"done\" must be true or false");
}
String title = rawTitle.strip();
if (title.isEmpty()) {
throw new RequestException(422, "title must not be empty");
}
if (title.codePointCount(0, title.length()) > MAX_TITLE_LENGTH) {
throw new RequestException(422,
"title must be at most " + MAX_TITLE_LENGTH + " characters");
}
return new NewTask(title, done);
}
An unknown field such as "id" is a 400, so a client can’t pick its own id and a typo like "titel" isn’t silently ignored. A blank title gets 422 Unprocessable Content: the server read the request fine, but a value breaks a rule. The length counts code points, so a title in Chinese gets 200 characters too.
Writing responses: the three lengths of sendResponseHeaders
sendResponseHeaders(status, length) sends the status line and headers, and its second argument says what kind of body follows. Every JSON response in the service goes through one method:
private static void sendJson(HttpExchange exchange, int status, String json)
throws IOException {
byte[] body = (json + "\n").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(status, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
}
private static void sendError(HttpExchange exchange, int status, String message)
throws IOException {
sendJson(exchange, status, Json.error(message));
}
Headers are set first, because they go out with the status. The length is in UTF-8 bytes, not characters: "Zoë" is 3 characters but 4 bytes. A delete sends no body:
private void deleteTask(HttpExchange exchange, long id) throws IOException, RequestException {
if (!store.delete(id)) {
throw new RequestException(404, "task not found");
}
exchange.sendResponseHeaders(204, -1);
}
The -1 matters, and so does the difference from 0. This program tries each kind of length, plus one that lies:
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
byte[] hello = "hello".getBytes(StandardCharsets.UTF_8);
server.createContext("/length", exchange -> {
exchange.sendResponseHeaders(200, hello.length); // exactly 5 bytes follow
try (var out = exchange.getResponseBody()) {
out.write(hello);
}
});
server.createContext("/chunked", exchange -> {
exchange.sendResponseHeaders(200, 0); // 0: any amount, sent in chunks
try (var out = exchange.getResponseBody()) {
out.write(hello);
out.write(hello);
}
});
server.createContext("/nobody", exchange -> {
exchange.sendResponseHeaders(204, -1); // -1: no body at all
exchange.close();
});
server.createContext("/toomany", exchange -> {
exchange.sendResponseHeaders(200, 2); // promises 2 bytes, writes 5
try (var out = exchange.getResponseBody()) {
out.write(hello);
} catch (IOException e) {
IO.println(" server: " + e.getMessage());
throw e;
}
});
server.start();
String base = "http://localhost:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
for (String path : List.of("/length", "/chunked", "/nobody", "/toomany")) {
IO.println(path);
var request = HttpRequest.newBuilder(URI.create(base + path))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
try {
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
var headers = response.headers();
IO.println(" " + response.statusCode() + " [" + response.body() + "]"
+ " content-length=" + headers.firstValue("content-length").orElse("-")
+ " transfer-encoding="
+ headers.firstValue("transfer-encoding").orElse("-"));
} catch (IOException e) {
IO.println(" client: " + e.getMessage());
}
}
}
server.stop(0);
}
It prints:
/length
200 [hello] content-length=5 transfer-encoding=-
/chunked
200 [hellohello] content-length=- transfer-encoding=chunked
/nobody
204 [] content-length=- transfer-encoding=-
/toomany
server: too many bytes to write to stream
client: HTTP/1.1 header parser received no bytes
A positive length sends a Content-Length header, and the body must be exactly that long. 0 doesn’t mean “empty”. It means “length unknown”, so the server sends the body in chunks of any size. -1 means no body. A 204 must never have one.
When we passed 0 with a 204, the JDK logged WARNING: sendResponseHeaders: rCode = 204: forcing contentLen = -1 to standard error. Writing more bytes than promised throws IOException. The handler lets it escape, so the server closes the connection. Our first version caught the exception and carried on, and its client hung until we killed it.
Status codes and one error shape
A client reads the status code first, so the service picks each one on purpose, and every error body has the same shape, {"error":"..."}:
- 400: the body can’t be read: bad JSON, an unknown field, a wrong type or bad UTF-8.
- 404: no such task, or no such path.
- 405: the path exists, but not with that method.
Allowlists the methods that work. - 413: the body is over 16 KiB.
- 415: the
Content-Typeisn’tapplication/json. - 422: the title is blank or too long.
- 500: a bug. The details go to the log, not to the client.
Main: start on a port, stop on a shutdown hook
Main reads an optional port from its arguments, starts the service with an in-memory store, and registers a shutdown hook:
package com.example.tasks;
import java.io.IOException;
import com.example.tasks.http.TaskServer;
import com.example.tasks.store.InMemoryTaskStore;
/** Starts the tasks service. Usage: Main [port], where the port defaults to 8080. */
public final class Main {
private Main() {
}
public static void main(String[] args) throws IOException {
int port = args.length == 0 ? 8080 : parsePort(args);
TaskServer server = TaskServer.start(new InMemoryTaskStore(), port);
IO.println("listening on port " + server.port());
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
IO.println("stopping");
server.stop(2);
IO.println("stopped");
}));
}
private static int parsePort(String[] args) {
try {
int port = Integer.parseInt(args[0]);
if (args.length == 1 && port >= 0 && port <= 65535) {
return port;
}
} catch (NumberFormatException e) {
// Fall through to the usage message.
}
System.err.println("usage: tasks [port] (0 to 65535, default 8080)");
System.exit(2);
return -1;
}
}
main returns straight away, and the JVM keeps running, because the server’s dispatcher thread isn’t a daemon thread.
A shutdown hook is a thread the JVM starts when it’s asked to exit, including on Ctrl+C and SIGTERM. This one calls stop(2), which stops accepting connections and gives requests in flight up to two seconds to finish. Hooks don’t run on kill -9.
Running the service and calling it with curl
The service runs from the module path, like any modular program. In the project folder, compile it and start it in one terminal:
$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.tasks
$ java -p out -m com.example.tasks/com.example.tasks.Main
listening on port 8080
Then call it from a second terminal. -d makes curl send a POST, and -w '%{http_code}\n' prints the status after the body:
$ curl -s localhost:8080/tasks
[]
$ curl -s -i localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Buy milk"}' | grep -v '^Date:'
HTTP/1.1 201 Created
Content-type: application/json
Content-length: 41
Location: /tasks/1
{"id":1,"title":"Buy milk","done":false}
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": "Walk the dog", "done": true}'
{"id":2,"title":"Walk the dog","done":true}
$ curl -s localhost:8080/tasks
[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Walk the dog","done":true}]
$ curl -s -X DELETE localhost:8080/tasks/1 -w '%{http_code}\n'
204
$ curl -s localhost:8080/tasks/1 -w '%{http_code}\n'
{"error":"task not found"}
404
$ curl -s -i -X PATCH localhost:8080/tasks/2 | grep -v '^Date:'
HTTP/1.1 405 Method Not Allowed
Allow: GET, DELETE
Content-type: application/json
Content-length: 31
{"error":"method not allowed"}
$ curl -s localhost:8080/tasks -d 'title=Walk the dog' -w '%{http_code}\n'
{"error":"Content-Type must be application/json"}
415
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": " "}' -w '%{http_code}\n'
{"error":"title must not be empty"}
422
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d '{"title": 42}' -w '%{http_code}\n'
{"error":"only strings, true and false are accepted as values"}
400
$ printf '{"title": "%s"}' "$(printf 'a%.0s' {1..20000})" > big.json
$ curl -s localhost:8080/tasks -H 'Content-Type: application/json' -d @big.json -w '%{http_code}\n'
{"error":"request body must not be larger than 16384 bytes"}
413
$ curl -s --http2 localhost:8080/tasks -o /dev/null -w '%{http_version}\n'
1.1
The grep drops the Date header, which changes every second. The JDK sends Content-type, not Content-Type, because it capitalizes only the first letter of a header name. Header names are case-insensitive, so clients don’t mind. The last command asked to upgrade to HTTP/2, and the server stayed on 1.1.
Pressing Ctrl+C in the first terminal printed stopping and stopped, and the JVM exited with status 130.
Checking the project with JDK tools only
The project’s run-checks.sh uses only javac and java. It compiles the module with warnings as errors, then compiles and runs checks/SmokeCheck.java, which starts the service on port 0 in its own JVM:
public static void main(String[] args) throws Exception {
try (TaskServer server = TaskServer.start(new InMemoryTaskStore(), 0);
HttpClient c = HttpClient.newHttpClient()) {
client = c;
base = "http://localhost:" + server.port();
checkEndpoints();
checkBadRequests();
checkConcurrentCreates();
}
if (failures > 0) {
IO.println(failures + " check(s) failed");
System.exit(1);
}
}
It calls every endpoint with HttpClient and compares status codes, headers and bodies, including 100 POST requests sent at once, which must get 100 different ids. Then the script starts the real Main on port 0, sends it SIGTERM and checks that the hook ran:
$ ./run-checks.sh
ok compiled com.example.tasks
ok smoke check: 45 checks passed
ok Main starts on port 0 and stops cleanly on SIGTERM
ok a bad port prints usage and exits 2
ok tests: 18 passed, 0 failed
ok java -p tasks.jar -m com.example.tasks starts and stops
ok jlink image with com.example.tasks,java.base,jdk.httpserver starts and stops
all checks passed
The first four lines are this part’s checks. It’s a smoke check, not a test suite. The last three come from the next part, which builds a test harness for this project and packages it with jlink and jpackage.
What com.sun.net.httpserver doesn’t do
com.sun.net.httpserver is a supported JDK API despite its name, exported by the jdk.httpserver module. It suits small internal services, tools and fake servers in tests. For a public production service, it leaves a lot for you to write:
- No HTTP/2.
HttpClientandcurl --http2both got HTTP/1.1,curl --http2-prior-knowledgegot no response, and the module has no HTTP/2 classes. - No router. Contexts match by string prefix, with no path parameters and no routing by method.
- No JSON or validation. The
Jsonclass is yours to maintain. - Limits are system properties, not an API. Time and header limits are
-Dflags such assun.net.httpserver.maxReqTime. - No WebSocket server, although
java.net.httphas a WebSocket client. - HTTPS is manual.
HttpsServerexists, but you configure theSSLContextyourself.
For production, the common choices are Jetty, a framework built on Netty such as Vert.x or Micronaut, and Spring Boot. They add routing, HTTP/2, JSON binding, TLS configuration and metrics. The store interface and the status codes in this post carry over to any of them.
What to remember
HttpClient.newHttpClient()builds a reusable client.sendblocks,sendAsyncreturns aCompletableFuture, andBodyHandlers.ofString()reads the body as text.- Set
connectTimeouton the client andtimeouton each request. Redirects aren’t followed unless you setRedirect.NORMAL. HttpServercontexts match by string prefix, so check the exact path and method in the handler. Port 0 picks a free port, andgetAddress().getPort()tells you which.sendResponseHeaderstakes a byte count: a positive number is the exact length,0means chunked, and-1means no body. Set headers before calling it.- Without an executor, one thread handles every request.
setExecutor(Executors.newVirtualThreadPerTaskExecutor())gives each request its own virtual thread. - The JDK has no JSON API. Write a strict, small subset by hand, or use Jackson or Gson.
- Cap request bodies, answer every error in one JSON shape, and stop the server from a shutdown hook.
The JDK’s HTTP server is enough for a small service, as long as you know which parts you’re now writing yourself.