A Server with com.sun.net.httpserver
Start the JDK's built-in HTTP server, handle exchanges correctly, and run handlers on virtual threads instead of one dispatcher thread.
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/path", exchange -> { ... });
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
server.stop(1);The JDK ships a small HTTP server in the jdk.httpserver module. It needs no dependencies, starts in milliseconds and is a good fit for health endpoints, internal tools, test stubs of third-party APIs, and learning what a framework does for you. If your project uses modules, add requires jdk.httpserver; to module-info.java.
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/health", exchange -> { ... });
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
The second argument to create is the socket backlog; 0 means the system default. Port 0 asks the operating system for any free port, which is what tests want; read the real one back with server.getAddress().getPort(). new InetSocketAddress(8080) listens on all interfaces, which a container needs; bind to the loopback address when the service must only be reachable from the same machine.
The exchange lifecycle
Each request arrives as an HttpExchange. The order of calls is fixed:
- Read what you need:
getRequestMethod(),getRequestURI(),getRequestHeaders(),getRequestBody(). - Set response headers with
getResponseHeaders(). - Call
sendResponseHeaders(status, length). A length greater than 0 is the exact byte count, 0 means chunked (length unknown), and -1 means no body at all. - Write the body to
getResponseBody()and close it, or callexchange.close().
Headers set after sendResponseHeaders are silently ignored, because they have already been written. Forgetting to close the exchange leaves the client waiting until it times out.
Threads
Without an executor, every handler runs on the server's single dispatcher thread, so one slow database call stalls every other request. Give the server Executors.newVirtualThreadPerTaskExecutor() (Java 21) and each exchange gets its own cheap virtual thread, which lets handlers use plain blocking JDBC and HTTP calls.
Errors and shutdown
If a handler throws, the server closes the connection without sending a response. The caller sees an IOException, not a 500, so wrap handlers in a try/catch that answers 500 and logs the cause. server.stop(seconds) stops accepting connections and waits up to that many seconds for running exchanges to finish; call it from a shutdown hook for a clean exit when the container stops.
Java 18 also added SimpleFileServer and the jwebserver command for serving static files, built on the same package.
Example
When to use it
- Expose /health and /ready endpoints from a batch worker that has no web framework.
- Stand up a fake payment provider on port 0 inside an integration test and point the real client at it.
- Serve a small internal admin tool from a single JDK-only jar.
More examples
Handlers on virtual threads
With the default executor the five requests would run one after another on the dispatcher thread and take about 2.5 seconds. With a virtual thread per exchange they overlap, so blocking code inside handlers stops being a throughput problem.
import com.sun.net.httpserver.HttpServer;
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(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/slow", exchange -> {
try {
Thread.sleep(500); // stands in for a slow database query
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
byte[] body = ("virtual=" + Thread.currentThread().isVirtual()).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/slow");
try (HttpClient client = HttpClient.newHttpClient()) {
long start = System.nanoTime();
List<CompletableFuture<HttpResponse<String>>> calls = new ArrayList<>();
for (int i = 0; i < 5; i++) {
calls.add(client.sendAsync(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString()));
}
for (var call : calls) {
IO.println(call.join().body());
}
long millis = Duration.ofNanos(System.nanoTime() - start).toMillis();
// Five 500 ms requests overlapped instead of queueing behind each other.
IO.println("overlapped: " + (millis < 2000));
} finally {
server.stop(0);
}
}
// Output:
// virtual=true
// virtual=true
// virtual=true
// virtual=true
// virtual=true
// overlapped: trueTurning handler exceptions into a 500
An unhandled exception gives the client nothing to read, which load balancers and client libraries report as a network failure. The wrapper is the hand-written version of the exception handler every web framework installs for you; a real one would also log the exception.
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// Wraps any handler so an exception becomes a 500 instead of a dropped connection.
HttpHandler safe(HttpHandler handler) {
return exchange -> {
try {
handler.handle(exchange);
} catch (Exception e) {
byte[] body = "{\"error\":\"internal\"}".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(500, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
} finally {
exchange.close();
}
};
}
void main() throws Exception {
HttpHandler broken = exchange -> {
throw new IllegalStateException("database is down");
};
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/raw", broken);
server.createContext("/safe", safe(broken));
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
for (String path : List.of("/raw", "/safe")) {
try {
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create(base + path)).build(),
HttpResponse.BodyHandlers.ofString());
IO.println(path + " -> " + response.statusCode() + " " + response.body());
} catch (IOException e) {
IO.println(path + " -> " + e.getClass().getSimpleName());
}
}
} finally {
server.stop(0);
}
}
// Output:
// /raw -> IOException
// /safe -> 500 {"error":"internal"}Known length versus chunked bodies
Use an exact length when the body is already in memory, and chunked encoding when you stream rows out of a database or file and cannot know the size in advance. Getting the length argument wrong is the most common cause of hung or truncated responses with this server.
import com.sun.net.httpserver.HttpServer;
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(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/fixed", exchange -> {
byte[] body = "all at once".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
server.createContext("/streamed", exchange -> {
exchange.sendResponseHeaders(200, 0); // 0 = length unknown, send chunks
try (OutputStream out = exchange.getResponseBody()) {
for (int i = 1; i <= 3; i++) {
out.write(("row " + i + "\n").getBytes(StandardCharsets.UTF_8));
}
}
});
server.createContext("/empty", exchange -> {
exchange.sendResponseHeaders(204, -1); // -1 = no body
exchange.close();
});
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
for (String path : List.of("/fixed", "/streamed", "/empty")) {
HttpResponse<String> r = client.send(
HttpRequest.newBuilder(URI.create(base + path)).build(),
HttpResponse.BodyHandlers.ofString());
IO.println(path + " " + r.statusCode()
+ " content-length=" + r.headers().firstValue("content-length").orElse("-")
+ " transfer-encoding=" + r.headers().firstValue("transfer-encoding").orElse("-")
+ " lines=" + r.body().lines().count());
}
} finally {
server.stop(0);
}
}
// Output:
// /fixed 200 content-length=11 transfer-encoding=- lines=1
// /streamed 200 content-length=- transfer-encoding=chunked lines=3
// /empty 204 content-length=- transfer-encoding=- lines=0
Discussion