Skip to main content

Command Palette

Search for a command to run...

Building My Own HTTP Server in Java using java.net

Updated
•13 min read•View as Markdown
Building My Own HTTP Server in Java using java.net
S

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 HttpRequest object.

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 HttpResponse object 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