How to Build a TCP Load Balancer from Scratch in Go - Part 2: Forwarding Requests to the Backend

GoLangNetworkingLoadBalancingOpenSourceBuildInPublic

Building something?

Join the TermTrix builders community

Join
How to Build a TCP Load Balancer from Scratch in Go - Part 2: Forwarding Requests to the Backend

Exactly. Since Part 1 stopped at unix.Accept(), Part 2 should naturally continue from the client FD returned by Accept().

I’d structure this section like this:

Reading and Parsing the Client Request

After accepting the client connection, we now have a client file descriptor.

clientFD, clientAddr, err := unix.Accept(fd)

The next question is:

How do we actually get the HTTP request from this connection?

That's where unix.Recvfrom() comes in.

1. Reading the Client's Request

Using unix.Recvfrom()

Once the client connects to our load balancer, it sends an HTTP request over the TCP connection.

For example:

GET /hello HTTP/1.1
Host: localhost:8000

At the socket level, however, this isn't received as a Go string or an HTTP object.

It's received as bytes.

We can read those bytes using:

buff := make([]byte, 2048)

n, _, err := unix.Recvfrom(clientFD, buff, 0)

if err != nil {
    fmt.Println("recv error:", err)
    return
}

data := buff[:n]

fmt.Println(string(data))

The output will look something like:

GET /hello HTTP/1.1
Host: localhost:8000
User-Agent: curl/8.0
Accept: */*

Here:

buff := make([]byte, 2048)

creates a buffer where we'll store the incoming data.

And:

n, _, err := unix.Recvfrom(clientFD, buff, 0)

reads data from the TCP connection.

n tells us how many bytes were actually received.

That's why we use:

data := buff[:n]

instead of processing the entire 2048-byte buffer.

2. What Are We Actually Receiving?

At this point, data is simply a sequence of bytes.

For example, the beginning of:

GET /hello HTTP/1.1

is represented internally as bytes:

[71 69 84 32 47 104 101 108 108 111 32 ...]

Those numbers are the ASCII byte values of the HTTP request.

We can see the difference with:

fmt.Println(data)
fmt.Println(string(data))

Output:

[71 69 84 32 47 104 101 108 108 111 32 72 ...]

and:

GET /hello HTTP/1.1
Host: localhost:8000

This is an important point in our TLB implementation:

The network gives us bytes. We have to interpret those bytes as an HTTP request.

Parsing the HTTP Request

Now that we've captured the request, we need to understand what's inside it.

For example:

GET /hello HTTP/1.1
Host: localhost:8000

The first line contains three important pieces:

GET        /hello        HTTP/1.1
│             │             │
Method       Path        HTTP Version

So we can parse it into something like:

type IncomingRequest struct {
    Method  string
    Path    string
    Version string
    Host    string
    Port    string
}

For the request:

GET /hello HTTP/1.1
Host: localhost:8000

we want:

Method  → GET
Path    → /hello
Version → HTTP/1.1
Host    → localhost
Port    → 8000

This information becomes useful later when TLB decides where the request should go.

Why Parse the Request?

At the moment, TLB can receive:

Client → TLB

But TLB needs to understand what it received before forwarding it.

Our flow is becoming:

Client
   │
   │ HTTP request
   ▼
TLB :8000
   │
   │ Recvfrom()
   ▼
Raw bytes
   │
   │ Parse
   ▼
HTTP Request
   │
   ├── Method
   ├── Path
   ├── HTTP Version
   ├── Host
   └── Port

Now TLB isn't just blindly receiving bytes—we've started understanding the request at the application layer.

What's next?

We have successfully:

Accept → Receive → Parse

But we still haven't sent the request anywhere.

The next step is where TLB starts behaving like an actual proxy:

Take the request we received from the client and forward it to the backend server.

Forwarding the Request to the Backend

We've now reached the interesting part.

TLB has already accepted the client connection and captured the HTTP request as bytes. Now we need to move those bytes to our backend server.

Our backend is running on:

127.0.0.1:3001

The flow is:

Client
   │
   │ Request
   ▼
TLB :8000
   │
   │ Forward
   ▼
Backend :3001
   │
   │ Response
   ▼
TLB
   │
   │ Forward response
   ▼
Client

1. Creating the Outbound Backend Connection

The connection between the client and TLB is one TCP connection.

To communicate with the backend, TLB needs to create another TCP connection.

backendFD, err := unix.Socket(
    unix.AF_INET,
    unix.SOCK_STREAM,
    0,
)

Now we have two sockets:

clientFD  → Client ↔ TLB

backendFD → TLB ↔ Backend

We then define our backend address:

backendAddr := unix.SockaddrInet4{
    Port: 3001,
    Addr: [4]byte{127, 0, 0, 1},
}

And connect to it:

err = unix.Connect(backendFD, &backendAddr)

if err != nil {
    fmt.Println("backend connect:", err)
    return
}

If everything works, the connection looks like:

TLB
 │
 │ Connect()
 ▼
127.0.0.1:3001

2. Forwarding the Request

We already received the client's request:

data := buff[:n]

For example:

GET /hello HTTP/1.1
Host: localhost:8000

Those bytes can now be written directly to the backend socket:

_, err = unix.Write(backendFD, data)

if err != nil {
    fmt.Println("backend write:", err)
    return
}

The data flow is now:

Client
   │
   │ HTTP request
   ▼
TLB
   │
   │ unix.Write(backendFD, data)
   ▼
Backend :3001

TLB is essentially acting as the middleman between the client and backend.

3. Receiving the Backend Response

The backend processes the request and sends a response back through the TCP connection.

We read that response using:

resBuff := make([]byte, 2048)

n, err := unix.Read(backendFD, resBuff)

if err != nil {
    fmt.Println("backend read:", err)
    return
}

response := resBuff[:n]

For example, the backend might return:

HTTP/1.1 200 OK
Content-Type: text/plain

Hello from backend!

At the socket level, once again, this is simply bytes.

So now:

Backend :3001
      │
      │ Response bytes
      ▼
    TLB
      │
      │ unix.Read()
      ▼
 response

4. Sending the Response Back to the Client

We've received the backend response.

The final step is to send those bytes through the original client FD:

_, err = unix.Write(clientFD, response)

if err != nil {
    fmt.Println("client write:", err)
    return
}

Now the complete request-response path is:

             TERMTRIX LOAD BALANCER

Client
  │
  │ Request
  ▼
clientFD
  │
  ▼
 TLB
  │
  │ Write()
  ▼
backendFD
  │
  ▼
Backend :3001
  │
  │ Response
  ▼
backendFD
  │
  ▼
 TLB
  │
  │ Write()
  ▼
clientFD
  │
  ▼
Client

Our complete flow so far

Accept
   ↓
Receive
   ↓
Parse
   ↓
Connect to Backend
   ↓
Forward Request
   ↓
Read Backend Response
   ↓
Send Response to Client

At this point, TLB has completed its first real proxy workflow:

Client → TLB → Backend → TLB → Client

The basic request forwarding works. 🚀

But currently, we're dealing with connections one at a time. The next challenge is making TLB handle multiple clients concurrently and then introducing multiple backend servers for actual load balancing.