This is the first article in a series where I document building an HTTP server in Java from raw sockets — no frameworks, no Tomcat, no Spring. Just java.net and whatever mistakes I make along the way.
Where I Started
Week one looked like this — everything crammed into a single main() method:
public class App {
public static void main(String[] args) {
try (ServerSocket sc = new ServerSocket()) {
sc.bind(new InetSocketAddress("127.0.0.1", 8080));
System.out.println("the server is ready");
while (true) {
Socket client = sc.accept();
System.out.println("Server accepted a new client. Processing...");
PrintWriter pw = new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(client.getOutputStream())
)
);
pw.println("HTTP/1.1 200 OK");
pw.println("Content-Type: text/html");
pw.println("Content-Length: 53");
pw.println();
pw.println("<html><body><h1>Bare-Metal is UP!</h1></body></html>");
pw.flush();
pw.close();
client.close();
}
} catch (Exception e) {
System.out.println("error in the main method with :" + e);
}
}
}
Ugly, but it worked. One accepted connection, one hand-built response, flush() and close() both there, done.
Then I started splitting this apart — pulling connection handling into its own ClientWorker class, and request parsing into a Dispatcher, to get ready for a thread pool and actual routing. That refactor is where I hit a bug that took me a genuinely embarrassing amount of time to find.
The Bug
The new ClientWorker looked, at a glance, like a clean translation of the same logic:
BufferedReader br = new BufferedReader(
new InputStreamReader(client.getInputStream())
);
String line;
if ((line = br.readLine()) != null) {
String res = Dispatcher.Rout(line);
PrintWriter pw = new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(client.getOutputStream())
)
);
pw.println(res);
}
client.close();
The code compiled. It ran. It accepted connections without a single exception. And the browser just spun on a blank screen, waiting forever for a response that never came.
The bug: I wrote the response with pw.println(res), then closed the raw client socket directly — never touching pw again. In the Week 1 version, flush() and close() were both sitting right there, two lines below the write. Somewhere in pulling the logic apart into a separate class, they quietly didn't make the trip.
To explain why forgetting two lines could freeze an entire response, I need to explain why this three-layer nested constructor exists in the first place:
PrintWriter pw = new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream())
)
);
The "Over-Engineered" Syntax That's Actually Genius
The first time you see this in Java, it looks like a joke. Three constructors nested inside each other just to write text to a socket? Why isn't there one simple NetworkWriter class that does all of this?
It turns out this isn't over-engineering — it's the direct, deliberate answer to a problem that would otherwise collapse a codebase under its own weight. To see why, you have to understand what I/O actually costs, and what happens if you try to avoid that cost the naive way.
What I/O Actually Means
I/O stands for Input/Output. In code, an I/O operation is any action that forces your program to step outside the memory the operating system assigned to it.
When your program touches a local variable, it's operating inside fast, safe RAM that belongs to it. But the moment you want to read a file off disk or send a packet over the network, your program has to hand off to the OS kernel and talk to actual hardware. That handoff — crossing from your process into the kernel and back — is slow. Not "slightly slower," but orders of magnitude slower than working in RAM. This is where Java's design problem starts.
The Nightmare of Class Explosion
Say you want a utility that writes data to a file. You build FileByteWriter. It works.
A week later, someone wants to write text Strings instead of raw bytes. Now you need FileStringWriter too.
A few days after that, the app needs to write to a network socket instead of a file. Now you need SocketByteWriter and SocketStringWriter. You're at four classes for two features.
Now imagine product wants encryption (SSL). And compression (GZIP). And buffering for performance. If you keep building one dedicated class per combination, your class count grows exponentially — roughly 2ⁿ for n independent features. You'd eventually need hundreds of classes like EncryptedBufferedSocketStringWriter, and half your day would go to remembering which one you actually need.
This is called class explosion, and it's exactly what Java's I/O designers built around using the Decorator Pattern.
Java's Actual Blueprint: Three Layers You Stack
Instead of one class per combination, Java splits I/O into small, single-purpose pieces you compose like building blocks:
1. The Connection Layer — the actual physical endpoint, like File or Socket. It only knows how to move raw bytes in or out.
2. The Transport Layer — converts between representations. A raw socket only understands bytes; wrapping it in OutputStreamWriter gives you a translator that turns your characters into the bytes the network actually needs.
3. The Performance/Feature Layer — the decorators you stack on top. This is where BufferedWriter comes in.
Sending data one byte at a time over a network is like sending a semi-truck to deliver a single kitchen sponge — technically it works, but you're paying for a full trip every single time. A buffer is a small warehouse: it holds your data in RAM until there's a full truckload (commonly 8KB), then ships it all in one trip.
Then PrintWriter goes on top of all of that, giving you convenient methods like println() so you can write clean text without thinking about any of the layers underneath.
Where My Bug Actually Came From
Here's the part that isn't in most tutorials: the buffer doesn't know your response is "done." It only knows two things — "am I full?" and "was I told to flush?" That's it.
My response was small — a few hundred bytes, nowhere close to the 8KB buffer threshold — so the buffer just sat there, holding my data, waiting for either more bytes to arrive or an explicit signal that would never come. Then I closed the raw socket directly, out from under the writer. The connection went away with the data still sitting in the buffer. From the browser's side, nothing had errored — it just never received a response, so it kept waiting.
The fix was two lines:
pw.flush();
pw.close();
flush() tells the buffer "stop waiting for a full truckload, ship what you have right now." close() does something similar internally — a PrintWriter's close() flushes automatically before it shuts down — which is why in real code you'll often see people rely on close() alone, or better, a try-with-resources block, since either one guarantees the flush happens even if an exception is thrown partway through. What actually broke my server was closing the socket directly and never touching the writer at all, so neither of those safety nets ever ran.
Why the "Ugly" Syntax Is Actually a Feature
The nested-constructor syntax isn't hiding this complexity from you — it's making every layer, and every decision, visible and swappable. Want to drop buffering because you need every byte to go out immediately (like a live streaming protocol)? Delete one line. Want to add compression? Wrap one more layer around the existing ones, and nothing else in your code has to change.
That's the Open-Closed Principle in practice: the system is open for extension, but closed for modification. You don't touch Java's stream, buffering, or formatting logic — you just compose new pieces on top of what's already there.
What looks like over-engineering on first read is actually one of the more resilient, composable designs in the standard library — and understanding why it's built that way is what turned an invisible, silent bug into two obvious lines of code.
*This is Part 1 of a series on building an HTTP server in Java from scratch. Part 2 covers what happened when I hit this same server with concurrent requests — and why "just add more RAM" doesn't fix what actually breaks. Code for this series is on GitHub at https://github.com/AnasMAC/java-baremetal-server/releases/tag/v1-blocking-server.
Top comments (0)