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.

SyntaxHttpServer 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:

  1. Read what you need: getRequestMethod(), getRequestURI(), getRequestHeaders(), getRequestBody().
  2. Set response headers with getResponseHeaders().
  3. 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.
  4. Write the body to getResponseBody() and close it, or call exchange.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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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: true

Turning 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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.