# Spring Boot is magic. So I built an HTTP server in Java to ruin the illusion.
Table of Contents
Introduction
Spring Boot makes building a backend almost embarrassingly easy. Slap @RestController on a class, throw a @GetMapping on a method, hit “run”, and boom… you’ve got a functioning HTTP server before your coffee’s done brewing. An embedded Tomcat server just… appears. Routes just… work. Annotations just… do things. Magic!
This is great, until I started asking myself: how does this actually work? What exactly is happening under the hood from the moment I make a GET request, to the moment I receive a 200 OK back from the server?
So, I did what curiosity demanded. I decided that I’d take a shot at building my own HTTP server from scratch in Java — just sockets, threads, and bytes.
The Goal: Develop a minimalistic system that provided enough functionality to allow me to expose some of the abstractions that Spring Boot was hiding from me.
Follow along — by the end, I’ll have pulled back the curtain on some of that sweet, sweet Spring Boot “magic”.
What We’re Building
Let’s not waste any time! So what’s really happening during the lifecycle of an HTTP request?
At a high level, we’re going to establish a client -> server connection, read incoming bytes off of a socket, parse them into a request object, find the right method to handle it, build a response object, and write bytes back. You can visualize this process like so:
Establish a connection ↓Read bytes off of a socket ↓Parse into a request object ↓Route request to a handler ↓Build a response object ↓Write back as bytesThat’s the whole job. Pretty simple, right? Spring Boot does this for us right out of the box, but today we’re going to do it ourselves.
Step 1: Accepting Connections
Our first job is to establish a connection between our client and our server. Java conveniently provides us with java.net.ServerSocket which we’ll use here.
You can think of a ServerSocket as a telephone operator whose job is to sit and wait for the phone to ring. Once a call comes in, the operator hands the caller off to a new private line (a Socket) to handle that specific conversation. The operator then returns to waiting for the next call.
All we need to do is create a ServerSocket bound to a port. We then call its accept() method, which blocks the calling thread until a client connects. When one does, you get back a new Socket object representing that connection. Wrap that whole thing in a loop and voilà, you’ve got a server that can accept connections forever:
public void start() throws IOException { final int PORT = 8080; try (ServerSocket serverSocket = new ServerSocket(PORT)) { while (true) { Socket client = serverSocket.accept(); // blocks until a client connects // ...handle the connection } }}That’s the whole foundation. Every HTTP server on the planet, including the embedded Tomcat that Spring Boot spins up for you, has some version of this loop running underneath. The rest of the work is what happens after accept() returns.
Once we’ve established a client connection, and received a Socket object back, we now need the ability to read and write messages over this connection. HTTP data is transferred as a byte stream (InputStream and OutputStream). We can access these streams by calling Socket.getInputStream() and Socket.getOutputStream().
But before we worry about HTTP specifically, let’s prove we can successfully move bytes through this socket. For this example, we’ll create a simple “echo server” which will read whatever the client sends us through InputStream, and send it right back through OutputStream.
That’ll look something like this:
public void start() throws IOException { final int PORT = 8080; try (ServerSocket serverSocket = new ServerSocket(PORT)) { while (true) { Socket client = serverSocket.accept(); // client connection established... InputStream inputStream = client.getInputStream(); OutputStream outputStream = client.getOutputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); PrintWriter writer = new PrintWriter(outputStream, true); //auto flush
String inputLine; // read a line from the buffer, write it back while ((inputLine = reader.readLine()) != null) { writer.println(inputLine); } client.close(); // close the connection when buffer is empty } }}Now let’s test it out. In our terminal:
$ echo "Hello, world!" | nc localhost 8080# Hello, world!^CSuccess! "Hello, world!" in -> "Hello, world!" out.
Now, I know what you’re thinking. This isn’t quite an HTTP server yet. It doesn’t speak the protocol. At this point, it’s just a glorified byte-pipe. Bytes come in via getInputStream(), bytes go out via getOutputStream(). Simply put, HTTP is just a universally agreed-upon format for what those bytes mean. We’ll teach it HTTP in Step 3.
Now, a Spring Boot controller never makes you touch a stream directly. You write something like this:
@PostMapping("/echo")public String echo(@RequestBody String body) { return body;}You get a String parameter handed to you, and your return value is sent back. But underneath, Tomcat is calling request.getInputStream() and reading bytes similarly to how we just did. Spring converts those bytes into a String (using an HttpMessageConverter), passes it to your method, converts your return value back to bytes, and writes them to response.getOutputStream(). The streams are still there. They’re just buried under a few layers of “convenience”.
Currently, our server can establish a connection to a client and read/ write bytes through a Socket. This is cool, but what happens when our server gets two requests at the same time?
Step 2: Handling Many Connections at Once
With our current setup, we’d have to wait for each request to finish before handling a new one. That doesn’t scale. Ideally, we want our server to be able to handle lots of requests simultaneously. That’s where we introduce concurrency.
Conceptually, we need a way for our server to hand off incoming connections to other threads. This way, as requests come in, our main ServerSocket thread can delegate the work and immediately go back to listening for the next connection.
There are a couple of ways we can do this in Java (virtual threads, NIO selectors, reactive frameworks). We’ll use a fixed thread pool with an arbitrary number of threads for this example:
ExecutorService pool = Executors.newFixedThreadPool(50);
public void start() { try (ServerSocket serverSocket = new ServerSocket(PORT)) { while (true) { Socket client = serverSocket.accept(); pool.submit(() -> handle(client)); // hand off, keep accepting } }}public void handle(Socket client) { // ...read/write bytes, close socket (from previous section)}The accept loop is now non-blocking with respect to request handling. accept() returns a Socket, we hand it to the pool, and immediately loop back to wait for another connection.
To prove that the thread pool is actually doing what we think it is, we can fire 50 concurrent connections at the server and clock the time it took to handle those connections.
For this test, I temporarily added Thread.sleep(100) in handle() so each request has a measurable clock time.
In our terminal:
$ time seq 1 50 | xargs -n1 -P50 -I{} sh -c 'echo "hello {}" | nc -q1 localhost 8080'
# Sequential (no thread pool): ~6.1 sec# Concurrent: ~1.2 secAs you can see, the thread pool has significantly improved our performance. The 1.2 seconds isn’t pure server time either. About a second of that is nc’s per-connection wait timer. The point is the contrast between sequential and concurrent.
Our server can now handle many connections at once, but every one of those connections is protocol-agnostic. The next layer is where those bytes start to mean something. Next, we’ll teach our server to read the bytes coming off the socket as structured HTTP requests.
Step 3: Parsing Requests
At the end of the day, bytes are just bytes. HTTP is what imposes structure on them. When we create a parsing layer, our job is to turn raw bytes -> request object.
To accomplish this, we must first understand the anatomy of an HTTP request since a request abides by strict syntactic standards. If our incoming request does not abide by these standards, we must consider it malformed and reject it.
An HTTP/1.1 request consists of four main parts:
Request-Line: The first line containing the method, target URI, and HTTP version, separated by spaces, terminated by CRLF. (e.g.GET /users HTTP/1.1\r\n)Request Headers: Key-value pairs providing metadata, each on a new line, terminated by CRLF (e.g.Host: example.com\r\n)Blank Line: A mandatory empty line (CRLF) to signify the transition from headers -> body.Body(optional): The payload of the request.
This comes together to look something like this:
POST /greeting HTTP/1.1\r\nHost: example.com\r\nUser-Agent: Mozilla/5.0\r\nContent-Type: text/plain\r\nContent-Length: 13\r\n\r\nHello, World!When we parse a request, our job is to turn this block of text into a structured HttpRequest object that we can work with.
To accomplish this, we want to “walk through” the request byte-by-byte, acknowledge what portion of the request we are currently in, and parse that specific portion accordingly. For this, we’ll build a State Machine that keeps an internal state variable that tells us what section of the request we are currently in.
For clarity, these are the data structures that we’ll be constructing from the raw request:
record HttpRequest(RequestLine requestLine, Map<String, List<String>> headers, byte[] body) {}record RequestLine(HttpMethod method, String target, String version) {}enum HttpMethod { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS }Here’s the skeleton of our state machine, with the internals stubbed out to keep it concise for this article (I encourage you to take a look at the full code in the repo):
private enum ParserState { REQUEST_LINE, HEADER_NAME, HEADER_VALUE, BODY, COMPLETE, ERROR}
ParserState state = ParserState.REQUEST_LINE;StringBuilder buffer = new StringBuilder();
int prev = -1;int curr;
while ((curr = in.read()) != -1) { switch (state) { case REQUEST_LINE -> { // read bytes into buffer until CRLF, then parse: // "GET /users HTTP/1.1" -> new RequestLine("GET", "/users", "HTTP/1.1"). // change state to HEADER_NAME. } case HEADER_NAME -> { // read until ':' -> capture headerName -> change state to HEADER_VALUE // if CRLF (blank line) -> change state to BODY } case HEADER_VALUE -> { // read until CRLF, insert (headerName, headerValue) into map, // change state back to HEADER_NAME (next header) } case ERROR -> throw new HttpParseException(400, "Malformed request"); }
if (state == ParserState.BODY) { // if Content-Length header is present, read exactly that many bytes // change state to COMPLETE } if (state == ParserState.COMPLETE) break; prev = curr;}
return new HttpRequest(requestLine, headers, body.length > 0 ? body : null);The loop reads one byte at a time. Depending on which state we’re in, that byte either gets accumulated into the current buffer (mid-token) or triggers a state transition (end of token, like \r\n or :). Once we transition to BODY, we leave the byte-by-byte loop and read the body in one shot. We read exactly n bytes where n = Content-Length. If there is no Content-Length header present, we skip this step entirely.
If we encounter any part of the request which is malformed (missing Host header, bare LF without CR, invalid HTTP version, etc.), we throw a MalformedRequestException.
This parser handles the basics (request line, headers, Content-Length framing, malformed input rejection). Production parsers handle chunked Transfer-Encoding, header folding, percent-decoding, and a dozen other edge cases I didn’t touch. The point isn’t to compete with Tomcat, but rather to make the shape of the work visible so that the next time you write @RequestBody User user in your Spring app, you’ve got an idea of what’s happening under the hood.
So now we’ve got a parsed HttpRequest which contains a method, a target, headers, and a body. Now what? We need to figure out what to do with it. This is where routing comes in. Next, we’ll build the mechanism that matches each endpoint with the code that handles it.
Step 4: Routing to a Handler
Let’s clarify exactly what our routing mechanism needs to do. Two things:
- Keep an internal
registrythat says “when aGET /greetingrequest comes in, run this code”. - Given an incoming request, we need to search through that registry to find the right code to run.
Our end goal is to create an intuitive interface that looks like this:
Router router = new Router();// register our routesrouter.get("/greeting", (req) -> { /* our desired action/method */ });router.post("/echo", (req) -> { /* our desired action/method */ });
RouteHandler handler = router.getHandler(request); // find the correct routeThe first thing we need to define is our RouteHandler. In our case, a handler is simply any method that accepts an HttpRequest object as a parameter, and returns an HttpResponse (we’ll cover response shapes in step 5). This handler will represent the logic that we wish to run for a particular route.
@FunctionalInterfacepublic interface RouteHandler { HttpResponse handle(HttpRequest request) throws Exception;}The @FunctionalInterface annotation is our contract. A RouteHandler is anything shaped like HttpRequest -> HttpResponse. The router doesn’t know if the handler queries a database, returns a hardcoded string, or launches missiles. It only knows about the shape.
Next, we need a way to uniquely identify a route by its method and its path. GET /users and POST /users are two different routes. Same path, different code. That’s where a RouteKey comes in:
public record RouteKey(HttpMethod method, String path) {}Now, we have a way to map a specific method to a path to create a key. Next, we need a way to map that key to a handler. This is where our Router class begins to come together.
Inside of this class, we will keep an internal registry for all of the available routes in our application. We’ll then create a few methods which will allow us to add specific routes to that registry:
public class Router {
private final Map<RouteKey, RouteHandler> registry = new HashMap<>();
public void get(String path, RouteHandler handler) { registry.put(new RouteKey(HttpMethod.GET, path), handler); } public void post(String path, RouteHandler handler) { registry.put(new RouteKey(HttpMethod.POST, path), handler); } // ... put(), patch(), delete() -> same shape}The Router is literally just a HashMap with a friendlier API. That’s it. get() and post() just wrap put() with the method baked in. Now that we have a way to store routes, the only question left to answer is how we look up those routes.
To solve this, we’ll create a method which decomposes the incoming HttpRequest, extracts the method and path from the RequestLine, constructs a RouteKey for that particular request, looks through the registry for a matching key, and returns the correct RouteHandler:
public RouteHandler getHandler(HttpRequest request) { HttpMethod method = request.requestLine().method(); String path = request.requestLine().target();
return registry.get(new RouteKey(method, path));}Notice that getHandler() returns a RouteHandler, not an HttpResponse. The router’s job ends at “here’s the method you should call.” Actually calling that method happens back in our handle() method.
Down the road, if we need to add any middleware (auth, logging, rate limiting, etc.), we can slot it in between lookup and invocation. Fuse those two steps together and you’d have to cram that logic into the router itself, which has nothing to do with routing.
Spring’s HandlerMapping makes the same call for the exact same reason. It doesn’t invoke the handler directly. Instead, it returns a HandlerExecutionChain which contains the handler itself, along with any interceptors that must run before or after it. Auth, logging, the same things we just talked about. Same seam, same reason.
public void handle(Socket client) { try ( BufferedInputStream input = new BufferedInputStream(client.getInputStream()); BufferedOutputStream output = new BufferedOutputStream(client.getOutputStream()); ){
HttpRequest request = parser.parseRequest(input); RouteHandler handler = router.getHandler(request); HttpResponse response = handler.handle(request); // ...write to client (next section) }}While we’re at it, we’ll also create a method for configuring our routes. You can create this method in its own config class, but for clarity I’ll just create a configureRoutes() method within our HttpServer class:
public class HttpServer { private final Router router = new Router(); private final RequestParser parser = new RequestParser(); private final int PORT = 8080;
public void start() throws IOException { configureRoutes();
try (ServerSocket serverSocket = new ServerSocket(PORT)) { while (true) { Socket client = serverSocket.accept(); pool.submit(() -> handle(client)); } } }
private void configureRoutes() { router.get("/greeting", req -> HttpResponse.ok("hello, world")); router.post("/echo", req -> HttpResponse.ok(req.body())); }
private void handle(Socket client) { // ...previous snippet }}Step 5: Building & Writing the Response
We’ve spent four sections turning bytes into something meaningful. Now we run the whole thing in reverse. The response layer is HttpResponse object → bytes on the wire.
A response has the same shape as a request: a status line, headers, a blank line, and a body.
HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 13\r\n\r\nHello, World!The data model mirrors the request side:
public class HttpResponse { private final int statusCode; private final String reasonPhrase; private final Map<String, String> headers; private final byte[] body;
// constructor, getters omitted...}Building one of these by hand every time would be tedious and error-prone. Every handler would have to remember to set Content-Length, pick a sensible Content-Type, and get the status code right. Three opportunities to screw it up.
We’ll centralize that work with factory methods:
public static HttpResponse okText(String body) { byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); Map<String, String> headers = new HashMap<>(); headers.put("Content-Type", "text/plain"); headers.put("Content-Length", String.valueOf(bodyBytes.length)); return new HttpResponse(200, headers, bodyBytes);}
// notFound(), badRequest()... same shape -> different status codeStatic factory methods provide us with a way to enforce correctness. One place to set Content-Length, one place to pick the default Content-Type, one place to fix when you get it wrong.
Now we need to write the response back to the client. Order matters: status line, headers, blank line, body. Get this wrong and the client can’t parse what you sent.
public void write(BufferedOutputStream out) throws IOException { StringBuilder sb = new StringBuilder(); sb.append("HTTP/1.1 ").append(statusCode).append(" ").append(reasonPhrase).append("\r\n"); headers.forEach((k, v) -> sb.append(k).append(": ").append(v).append("\r\n")); sb.append("\r\n"); out.write(sb.toString().getBytes(StandardCharsets.UTF_8)); if (body != null && body.length > 0) out.write(body); out.flush();}Content-Length is very important here. It tells the client where the response ends. Without it (or chunked encoding, which we didn’t build), the client doesn’t know if it’s done reading, so it just… waits. For eternity.
Now the full lifecycle fits in one method:
public void handle(Socket client) { try ( BufferedInputStream input = new BufferedInputStream(client.getInputStream()); BufferedOutputStream output = new BufferedOutputStream(client.getOutputStream()); ) { HttpRequest request = parser.parseRequest(input); RouteHandler handler = router.getHandler(request); HttpResponse response = handler == null ? HttpResponse.notFound() : handler.handle(request); response.write(output); } catch (Exception e) { // ... error handling }}That’s the entire request lifecycle in ten lines. Accept, parse, route, invoke, write. No matter how much framework you pile on top, the loop at the bottom looks like this. Tomcat’s version is more defensive, more configurable, and significantly more battle-tested, but you get the point.
One last look at Spring. In your controller method, return user; is doing a lot. Under the hood, Spring wraps your object in a ResponseEntity, picks an HttpMessageConverter based on the Accept header (MappingJackson2HttpMessageConverter for JSON by default), serializes the object to bytes, sets Content-Length and Content-Type, and writes it all to the output stream. The factory methods we just wrote do the same job, but for strings. The difference is that Spring’s converters know how to turn any object into bytes, and will pick the correct converter based on what the client asked for.
What I Didn’t Build
The server we just built handles the happy path. Real HTTP servers handle a hundred ugly edge cases on top of that. Here’s a quick map of what I deliberately skipped, so you know where the gaps are.
Chunked Transfer Encoding. Our parser requires a Content-Length header to read the body. That works just fine in instances where the sender knows the body size upfront, but breaks for streaming responses where the length isn’t known until the last byte. HTTP/1.1’s answer is chunked encoding. With chunked encoding, the body comes in pieces, each prefixed with its own length, terminated by a zero-length chunk. Not too hard conceptually, but it’s another state in the parser and another branch in the response writer.
Path variables and pattern matching. This is probably the biggest “wait, can you actually use this?” gap. Our router does exact-string matching: /users works, /users/1234 would need to be registered separately. Real routers parse path templates such as /users/{id} with either a regex or a trie, capture the matched variables, and stash them on the request so the handler can read them. This is what makes REST APIs feel like REST APIs.
Virtual Threads. I used a fixed thread pool in Step 2. This model will scale to a few thousand concurrent connections, but OS thread overhead becomes the bottleneck. For ~20 years, Java’s answer to “more concurrency” was async and reactive code (Reactor, WebFlux). The tradeoff was increased complexity for more connections-per-thread. Java 21’s virtual threads eliminates that tradeoff. Tomcat 10.1+ supports them.
Hardening. Currently, our server will happily fall over to anyone who looks at it funny. There are a couple of examples worth naming. First, request size limits: a malicious client can send a 10GB request line with no newlines and our parser will buffer it all into memory before exploding. Real servers cap header sizes and reject anything over the limit. Second, slowloris: a client opens a connection, sends one byte every thirty seconds, and never finishes the request. The thread pool fills up with idle connections and the server stops accepting new ones. Real servers enforce read timeouts. There’s more. TLS attacks, header injection, request smuggling, you get the point. Production HTTP servers need to be bulletproof.
Closing Thoughts
Accept, parse, route, invoke, write. Five verbs, five sections, and the whole request lifecycle fits in one handle() method. The black box has fewer sides now.
At the end of the day, when you use a framework, you’re inheriting decisions that someone else made, and code that you never had to write. That’s leverage. Most of the time, it’s exactly what you want. The “magic” that everyone talks about? It’s just layers, and most days you don’t need to look under them.
The value of building something from scratch isn’t that you’d ever ship it. It’s that when something breaks, you know where to look to fix it. That’s the real leverage.
If you’re feeling ambitious, I encourage you to fork the repo, pick one thing from the “What I Didn’t Build” section, and implement it yourself.