usharik.dev

← All articles

Learning HTTP by writing a tiny web server in Java

Published · Alex Usharovski

First published on Habr (in Russian) on 21 February 2019; this is a rewritten and updated version.

When I started teaching Java, I needed a way to explain HTTP that would stick. Slides did not work. What worked was a server: open a socket, read what the browser sends, write an answer by hand, and watch the page appear. Nothing explains a protocol better than its smallest working implementation.

I published that server on Habr in 2019. It worked in a browser, and it was also wrong in ways that are worth a lesson of their own. Here is the corrected version for Java 21, and what changed.

Two files: TinyHttpServer.java on a raw socket and JdkServer.java on the built-in server. Run either with java TinyHttpServer.java and open http://localhost:8080/.

What the browser sends

Type http://localhost:8080/hello into the address bar and the browser opens a TCP connection and sends plain text:

GET /hello HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 ...
Accept: text/html,application/xhtml+xml,...

The first line is the request line: method, path, protocol version. Then come headers, one Name: value per line. An empty line ends the head of the request. If there is a body (a submitted form, JSON), the Content-Length header says how many bytes follow the empty line.

Lines end with \r\n, not just \n. HTTP inherited that from older text protocols.

What the server answers

The answer has the same shape: a status line, headers, an empty line, the body.

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 62
Connection: close

<!doctype html><p>Привет всем! Hello, everyone!</p>

Content-Type tells the browser how to show the body and in which encoding. Content-Length tells it where the body ends. Connection: close says the server will close the connection after this answer.

What was wrong in 2019

The old server read the request like this:

while (!input.ready()) ;           // wait for the first line
while (input.ready()) {            // read "everything that has arrived"
    System.out.println(input.readLine());
}

Three problems hide in these four lines.

  1. A busy wait. while (!input.ready()) ; spins a CPU core at 100% until the first bytes arrive.
  2. **ready() is not "the request is complete".** It only means some bytes are already buffered. A request split across two TCP packets is read in part, and the server answers too early. HTTP says where the head ends: at the empty line. The server must read until it, not until the buffer happens to be empty.
  3. **No Content-Length in the answer.** The browser could only find the end of the page because the server closed the connection. Keep-alive, the default in HTTP/1.1, assumes the opposite.

It also handled one client at a time, and wrote line ends with println, so \n on Linux instead of \r\n.

The corrected server

Each connection is handled in its own virtual thread. They are cheap, and blocking code stays simple:

try (ServerSocket server = new ServerSocket(8080);
     var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    while (true) {
        Socket socket = server.accept();
        pool.submit(() -> handle(socket));
    }
}

handle follows the protocol step by step:

// 1. The request line: METHOD PATH VERSION
String requestLine = in.readLine();
if (requestLine == null || requestLine.isBlank()) return;
String[] parts = requestLine.split(" ");
if (parts.length != 3) {
    send(out, 400, "Bad Request", "text/plain", "Bad request line\n");
    return;
}
String method = parts[0], path = parts[1];

// 2. Headers, one per line, until an empty line
String line;
int contentLength = 0;
while ((line = in.readLine()) != null && !line.isEmpty()) {
    int colon = line.indexOf(':');
    if (colon > 0 && line.substring(0, colon).equalsIgnoreCase("Content-Length")) {
        contentLength = Integer.parseInt(line.substring(colon + 1).trim());
    }
}

// 3. A body only if Content-Length says so (POST, PUT...)
char[] body = new char[contentLength];
int read = 0;
while (read < contentLength) {
    int n = in.read(body, read, contentLength - read);
    if (n < 0) break;
    read += n;
}

Header names are case-insensitive, hence equalsIgnoreCase. The body is read in a loop because one read may return fewer characters than asked.

The answer computes Content-Length from the bytes of the body. "Привет" is 6 characters but 12 bytes in UTF-8, and the browser counts bytes:

private static void send(OutputStream out, int status, String reason, String type, String body) throws IOException {
    byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
    String head = "HTTP/1.1 " + status + " " + reason + "\r\n"
            + "Content-Type: " + type + "\r\n"
            + "Content-Length: " + bytes.length + "\r\n"
            + "Connection: close\r\n"
            + "\r\n";
    out.write(head.getBytes(StandardCharsets.US_ASCII));
    out.write(bytes);
    out.flush();
}

Routing is a plain if: GET / returns the page, POST /echo returns the request body, everything else gets 404 Not Found.

Try it with curl

curl -i prints the status line and headers, which is the whole point of the exercise:

$ curl -i http://localhost:8080/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 62
Connection: close

<!doctype html><p>Привет всем! Hello, everyone!</p>

$ curl -X POST --data 'ping=1' http://localhost:8080/echo
ping=1

$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/nope
404

The server log shows what curl sent, including the Content-Length: 6 and Content-Type: application/x-www-form-urlencoded of the POST. Five parallel requests all get 200, each in its own virtual thread. A connection that sends garbage instead of a request line gets 400 Bad Request.

What this server still does not do

It is a teaching server, and its limits are lessons too.

The same with the JDK's own server

The JDK has had a small HTTP server since Java 6, in com.sun.net.httpserver. It parses requests, handles Content-Length and keep-alive connections for you (HTTPS is a separate class, HttpsServer), and with a virtual-thread executor it scales the same way:

HttpServer server = HttpServer.create(new InetSocketAddress(8081), 0);
server.createContext("/", exchange -> {
    byte[] body = "<!doctype html><p>Hello from com.sun.net.httpserver</p>\n".getBytes(StandardCharsets.UTF_8);
    exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
    exchange.sendResponseHeaders(200, body.length);
    try (var out = exchange.getResponseBody()) {
        out.write(body);
    }
});
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();

sendResponseHeaders takes the body length in bytes, the same Content-Length lesson in API form. For a quick look at a folder of files, Java 18 and later also ship jwebserver, a command-line static file server.

Takeaways

Writing this server is still the best first lesson on the web I know. After it, GET, POST, headers and status codes stop being magic words.

Source files