A web server from raw bytes, and the framing bugs nobody warns you about
Every backend developer leans on a framework, Spring or Express or whatever, and almost none of them have seen what it actually does. It is not much. Underneath, a web server is a function from bytes to a request, and from a response to bytes. Building that function by hand takes an afternoon, and it teaches you the handful of things that go wrong in production precisely because the framework normally hides them.
The whole protocol is text (in HTTP/1.1)
When a browser hits your server, it opens a socket and writes this:
GET /users/42?fields=name HTTP/1.1\r\n
Host: example.com\r\n
Accept: application/json\r\n
\r\n
That is the entire request. A request line, some headers, a blank line, and an optional body. The socket hands you bytes; the protocol is the agreement about how to give those bytes shape. Parsing is mostly splitting.
String[] lines = raw.split("\r\n");
String[] start = lines[0].split(" "); // method, path, version
String method = start[0]; // "GET" is the whole intent
String path = start[1];
Three details bite here, and each is a real bug I have watched people ship.
Headers are case-insensitive. Host and host and HOST must mean the same key. The spec says so, and clients rely on it. If you store headers in a plain map keyed by the literal string, you will one day fail to find Content-Type because someone sent content-type. Lowercase every key on the way in.
The blank line is the frame. \r\n\r\n is the only thing separating the headers from the body. It is not decoration; it is how the receiver knows the headers ended and the body began. Forget it in a response and the browser keeps reading your body as headers.
Percent-encoding is not optional. A space in a URL arrives as %20, an ampersand as %26. The query string ?q=a%20b is a b. You have to decode the escapes back into real characters, or every search with a space breaks.
The Content-Length bug
When you respond, you tell the browser exactly how many bytes the body is, so it knows when to stop reading:
String body = "{\"name\":\"José\"}";
// WRONG, on a string with non-ASCII:
int len = body.length(); // counts characters
// RIGHT:
int len = body.getBytes(StandardCharsets.UTF_8).length; // counts bytes
String.length() counts characters, but Content-Length is a byte count, and in UTF-8 a character like é is two bytes. Get this wrong and the browser either hangs waiting for bytes that never come, or truncates the body mid-character. It works perfectly on your ASCII test data and breaks the first time a user named José shows up. This is the single most common hand-rolled-HTTP bug, and the framework was quietly calling getBytes for you the whole time.
404 is not 405
Routing is matching the method and path to a handler. The interesting part is the failure codes, because there are three and people conflate the first two constantly.
GET /users/42matches a route: 200.GET /nopematches nothing: 404 Not Found. There is no such resource.GET /userswhen onlyPOST /usersexists: 405 Method Not Allowed. The resource exists; the verb is wrong.- The handler throws: 500. Your bug, not the client's.
The 404-versus-405 distinction matters because it is the difference between "this address does not exist" and "this address exists but you cannot do that to it." Returning 404 for a wrong method hides a real route from clients and tools that probe with OPTIONS. The way to get it right is to check path-match and method-match separately: if no path matches at all, 404; if a path matches but no method does, 405.
Path parameters are the one genuinely clever bit. A route pattern /users/:id matches /users/42 and captures 42 as id. Match segment by segment, treat a :name segment as a wildcard that captures, and let the most specific route win so /users/new beats /users/:id.
Where the toy ends
Be honest about what this is not. A real server has to handle things this version ignores: chunked transfer encoding when the body length is not known up front, keep-alive and pipelining so one socket serves many requests, TLS, and timeouts and backpressure so a slow client cannot tie up a thread forever. And the "it is all text" framing is specifically HTTP/1.1; HTTP/2 and HTTP/3 are binary framed protocols with multiplexed streams, where this mental model stops being literally true.
But the core never changes: bytes become a request, a handler produces a response, the response becomes bytes. Once you have written that loop yourself, the framework stops being magic and becomes a set of defaults you now understand well enough to override. That is the entire point of building it once.