Building My Own HTTP Server in Java using java.net

I'm Saroj Bist, a self-taught web developer, learning and building with the MERN stack. I started with front-end tools like ReactJS and Tailwind CSS, and slowly stepped into full-stack development, working on real projects and solving real problems. This blog is where I share what I’m learning — from small bugs to full project insights — in a way that helps both me and anyone on a similar path.
When we use Spring Boot, writing an API is surprisingly easy.
@GetMapping("/products")
public List<Product> getProducts() {
return productService.getProducts();
}
Building REST APIs with Spring Boot often feels effortless. A browser sends an HTTP request, Spring Boot somehow understands it, routes it to the correct controller, executes our business logic, and returns a response back to the client. As developers, we usually focus on writing controllers and services, while everything happening underneath remains hidden behind the framework. It works so seamlessly that it's easy to forget there's an entire networking stack making this possible.
That made me curious about what actually happens before Spring Boot or even a Servlet receives a request. How does a browser communicate with a Java application? Who accepts the TCP connection? Who reads the incoming bytes? Who understands that those bytes represent an HTTP request? Instead of starting with Servlets or Tomcat, I decided to go one layer deeper and build a tiny HTTP server using only Java's java.net package. By the end of this exercise, frameworks like Tomcat and Spring Boot no longer felt like magic—they felt like well-designed abstractions built on concepts I had implemented myself.
Step 1 — Becoming a Server
A normal Java program starts like this.
public class Main {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
This program prints something and exits. It isn't a server.
A server needs to do something different.
It needs to:
Listen on a port.
Wait for clients.
Accept connections.
Communicate with them.
Java provides ServerSocket for exactly this purpose.
ServerSocket serverSocket = new ServerSocket(8080);
"Hey Operating System, please reserve port 8080 for me. Whenever someone tries to connect to this port, notify my program."
Your application doesn't own the network directly. The Operating System does.
ServerSocket is simply Java asking the OS to create a listening socket.
Step 2 — Waiting for a Client
Next comes one of the most important methods.
Socket clientSocket = serverSocket.accept();
This line blocks.
Nothing happens until someone connects.
When Chrome opens:
http://localhost:8080
the operating system accepts the TCP connection and returns a brand new Socket.
Think of it like this.
Chrome
│
▼
TCP Connection
│
▼
Socket
ServerSocket keeps listening.
Each connected client gets its own Socket.
Step 3 — Receiving Data
A socket represents a communication channel.
It can both receive and send data.
To receive data:
InputStream inputStream = clientSocket.getInputStream();
One misconception I had initially was that creating an InputStream somehow started reading data.
It doesn't.
It simply gives us access to the incoming bytes.
Nothing is removed until we actually read from it.
Step 4 — Streams
This was probably the biggest concept for me.
A stream is not a collection.
A stream is a continuous flow of data.
Imagine a conveyor belt.
Chrome
↓
Bytes
↓
Socket Buffer
↓
InputStream
↓
Your Java Program
The browser keeps sending bytes.
The operating system stores them in the socket's receive buffer.
Your program consumes those bytes whenever it calls a read method.
Step 5 — Converting Bytes Into Text
Browsers send bytes.
Humans read text.
So we wrap the stream.
BufferedReader reader =
new BufferedReader(
new InputStreamReader(inputStream));
Now we can read one line at a time.
String line = reader.readLine();
Notice something.
readLine() doesn't read everything.
It only consumes enough bytes to produce the next complete line.
The remaining bytes stay inside the socket buffer waiting for the next read.
This is why streams have a concept of current position.
Every read moves that position forward.
Step 6 — The First Surprise
When I printed everything the browser sent, I got something like this.
while ((line = reader.readLine()) != null) {
System.out.println(line);
if (line.isEmpty()) {
break;
}
}
GET / HTTP/1.1
Host: localhost:8080
Connection: keep-alive
User-Agent: Mozilla/5.0
Accept: text/html
Cookie: refresh_token=...
That was a huge realization.
Chrome wasn't sending JSON.
It wasn't sending Java objects.
It was sending plain text following a protocol called HTTP.
Step 7 — Understanding HTTP Requests
Every HTTP request follows the same structure.
GET /products HTTP/1.1
Host: localhost:8080
User-Agent: Chrome
Accept: application/json
Cookie: token=abc123
{"id":1}
Let's break it down.
Request Line
The first line tells the server what the client wants.
GET /products HTTP/1.1
Contains:
Method
Path
HTTP Version
Headers
Everything after the request line is a header.
Host: localhost:8080
User-Agent: Chrome
Accept: application/json
Cookie: token=abc123
Headers are metadata.
Every header follows:
Key: Value
Examples include:
Host
Authorization
Cookie
Content-Type
User-Agent
Accept
Blank Line
After the last header comes an empty line.
This blank line is not data.
It is a separator.
It tells the parser:
Headers are finished.
Everything after this belongs to the request body.
Body
The body is optional.
For GET requests it is usually empty.
For POST requests it often contains JSON.
{
"name": "Saroj"
}
Step 8 — Why Parse?
Printing HTTP text is nice.
Building applications with giant strings is not.
Instead of:
GET /products HTTP/1.1
I wanted this.
request.getMethod();
request.getPath();
request.getVersion();
This process is called Parsing.
Parsing means converting raw text into structured objects that our application can easily understand.
Step 9 — Parsing the Request Line
Reading the request line is straightforward.
String requestLine = reader.readLine();
Splitting it:
String[] parts = requestLine.split(" ");
Produces:
GET
/products
HTTP/1.1
Which becomes
HttpRequest request =
new HttpRequest(
parts[0],
parts[1],
parts[2]
);
Step 10 — Parsing Headers
Headers naturally map to key-value pairs.
Host: localhost:8080
becomes
Host → localhost:8080
So a Map<String, String> is the perfect choice.
Map<String, String> headers = new HashMap<>();
We continue reading until the blank line.
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
break;
}
String[] headerParts = line.split(": ", 2);
headers.put(headerParts[0], headerParts[1]);
}
Notice that the blank line isn't stored.
It simply tells us:
Stop parsing headers.
Step 11 — Creating a Proper Request Object
Instead of passing strings everywhere, I created my own request object.
HttpRequest request =
new HttpRequest(
method,
path,
version,
headers
);
Now my program works with a structured object instead of raw HTTP text.
Printing it gives:
HttpRequest{
method='GET',
path='/',
version='HTTP/1.1',
headers={
Host=localhost:8080,
User-Agent=Mozilla/5.0,
Cookie=...
}
}
At this point I had unknowingly built a tiny version of what Tomcat does internally.
Parsing vs Serialization
While building this project I also learned an important distinction.
Incoming requests are parsed.
Raw HTTP Text
↓
Parse
↓
HttpRequest Object
Outgoing responses do the opposite.
HttpResponse Object
↓
Serialize
↓
Raw HTTP Text
These are opposite operations.
One converts text into objects.
The other converts objects back into text.
What's Next?
Right now my server can:
Accept TCP connections.
Read bytes from a socket.
Understand HTTP requests.
Parse request lines.
Parse headers.
Create a structured
HttpRequestobject.
But there is one problem.
Chrome sends me a request...
My server understands it...
And then closes the connection.
The browser eventually shows:
ERR_EMPTY_RESPONSE
Why?
Because HTTP communication is a conversation.
The client sends a request.
And finally, we'll manually send our very first HTTP response from Java.
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 11
Hello World
HTTP Response Structure
Every HTTP response sent by a server follows the same structure.
HTTP/1.1 200 OK ← Status Line
Content-Type: text/plain ← Headers
Content-Length: 11
Hello World ← Body
1. Status Line
HTTP/1.1 200 OK
Tells the client:
HTTP Version → Which HTTP protocol is being used.
Status Code → Result of the request (200, 404, 500...).
Reason Phrase → Human-readable description of the status.
Example:
HTTP/1.1 404 Not Found
2. Headers
Headers provide metadata about the response.
Format:
Key: Value
Content-Type
Content-Type: text/plain
Tells the client:
"How should you interpret the response body?"
Examples:
Content-Type: text/plain
Content-Type: application/json
Content-Type: text/html
Content-Type: image/png
Content-Type: application/pdf
Content-Length
Content-Length: 11
Tells the client:
"The response body contains exactly 11 bytes."
This lets the client know when the body has been completely received.
3. Blank Line
(Just one empty line)
Purpose:
Separates headers from the body.
Without this separator, the client wouldn't know where the headers end and where the actual data begins.
4. Body
The actual data sent by the server.
Example (Plain Text):
Hello World
Example (JSON):
{
"success": true,
"message": "Products fetched successfully",
"data": "useful data
}
Example (HTML):
<h1>Hello World</h1>
Example (Image):
<PNG Binary Bytes>
HTTP doesn't care what the body contains.
It simply transports bytes.
The Content-Type header tells the client how to interpret those bytes.
Complete Example
HTTP/1.1 200 OK ← Status Line
Content-Type: text/plain ← Response Metadata
Content-Length: 11 ← Size of Body (in bytes)
Hello World ← Actual Response Data
HTTP Response Flow
HttpResponse Object
│
▼
Serialize
│
▼
Raw HTTP Response
│
▼
Chrome
│
▼
Parse
│
▼
Display Body
Designing the Response Object
Just like I created a HttpRequest class for incoming requests, I wanted a dedicated class for outgoing responses.
public class HttpResponse {
private final String version;
private final int statusCode;
private final String reasonPhrase;
private final Map<String, String> headers;
}
Notice how similar this is to an actual HTTP response.
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 42
Hello World
The object simply represents each part of the HTTP protocol.
| HTTP Response | Java Object |
|---|---|
| HTTP Version | version |
| Status Code | statusCode |
| Reason Phrase | reasonPhrase |
| Headers | Map<String, String> |
At this point, the only thing missing was converting this object into actual HTTP text.
Building the Response
Instead of writing directly to the socket, I created a separate ResponseBuilder.
Its responsibility is simple.
Convert a
HttpResponseobject into a valid HTTP response string.
This is exactly what serialization means.
Step 1 — The Status Line
Every HTTP response begins with a status line.
HTTP/1.1 200 OK
Instead of hardcoding the entire line, I built it from the response object.
responseBuilder.append(response.getVersion())
.append(" ")
.append(response.getStatusCode())
.append(" ")
.append(response.getReasonPhrase())
.append("\r\n");
This produced
HTTP/1.1 200 OK
followed by the required line ending.
Step 2 — Response Headers
Next came the headers.
I stored them inside a Map.
headers.put("Content-Type", "text/html");
headers.put(
"Content-Length",
String.valueOf(body.getBytes(StandardCharsets.UTF_8).length)
);
One thing I learned here is that HTTP measures body size in bytes, not characters.
That's why I used
body.getBytes(StandardCharsets.UTF_8).length
instead of
body.length()
For plain English text they're usually the same.
But with Unicode characters like:
नमस्ते
the number of characters and bytes are different.
Production servers always calculate Content-Length using bytes.
Step 3 — Serializing Headers
Headers are simply key-value pairs.
Content-Type: text/html
Content-Length: 42
So serialization became very straightforward.
for (Map.Entry<String, String> entry : response.getHeaders().entrySet()) {
responseBuilder
.append(entry.getKey())
.append(": ")
.append(entry.getValue())
.append("\r\n");
}
This is actually the exact opposite of request parsing.
Earlier we converted
Host: localhost
into
headers.put("Host", "localhost");
Now we're doing the reverse.
headers
↓
Host: localhost
Step 4 — The Blank Line
HTTP requires one empty line between the headers and the body.
responseBuilder.append("\r\n");
This single line tells the browser:
Headers are finished. The response body starts next.
Interestingly, during request parsing we looked for this exact blank line to know where to stop reading headers.
Now we're generating it ourselves.
Step 5 — The Body
Finally came the actual data.
String body = "hello world sssssssssssssssssssssssssss";
responseBuilder.append(body);
The body can contain almost anything.
Plain text
HTML
JSON
Images
PDFs
Videos
HTTP doesn't care.
It simply transports bytes.
The Content-Type header tells the client how those bytes should be interpreted.
The Final HTTP Response
At the end of serialization, the StringBuilder contained something like this.
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 37
hello world sssssssssssssssssssssssssss
This is exactly what Tomcat eventually sends to the browser.
No magic.
Just text following the HTTP protocol.
Writing to the Socket
Once the response was built, sending it was surprisingly simple.
OutputStream outputStream = clientSocket.getOutputStream();
BufferedWriter writer =
new BufferedWriter(
new OutputStreamWriter(outputStream));
writer.write(responseBuilder.toString());
writer.flush();
The important part here is flush().
A BufferedWriter stores data in memory first.
Calling flush() forces those bytes to be written to the socket immediately.
Once Chrome received those bytes, it parsed the response exactly the same way my server had parsed the request earlier.
The Full Picture
At this point, I had manually implemented the complete HTTP request-response cycle.
Chrome
│
▼
HTTP Request (Text)
│
▼
TCP Socket
│
▼
My Java Server
│
▼
Parse
│
▼
HttpRequest
│
Business Logic
│
▼
HttpResponse
│
Serialize
│
▼
HTTP Response (Text)
│
▼
TCP Socket
│
▼
Chrome
Looking back, this was the moment Spring Boot stopped feeling magical.
When we return a ResponseEntity from a controller, Spring isn't inventing a new protocol.
Tomcat simply builds an HTTP response exactly like we did—only much faster, with support for keep-alive connections, compression, chunked transfer encoding, HTTP/2, HTTPS, error handling, and thousands of concurrent clients.
Understanding this made frameworks feel less like black boxes and more like powerful abstractions built on concepts I already understood.
Source Code: https://github.com/sarojbist/java-basics/tree/main/javadotnet



