Load Balnacer

Building a Toy HTTP Load Balancer from Scratch in Go

I wanted to build a load balancer from scratch, mostly for fun, but also because load balancing is one of those infrastructure ideas that sounds simple until you actually try to make it work.

At the surface, a load balancer does one job:

Take incoming traffic and send it to one of several backend servers.

That is the friendly version. The real version has more texture. Which backend should get the request? What if one backend is down? What if the backend accepts the connection but never responds? How do you know what the balancer is doing? And how do you make the behavior visible enough to learn from?

This project answers those questions with a small Layer 7 HTTP load balancer written in Go. It is not meant to replace NGINX, HAProxy, Envoy, or a cloud load balancer. It is a learning project: compact, runnable, and honest about the core moving parts.

The finished project includes:

Why HTTP First?

Load balancers usually sit at either Layer 4 or Layer 7.

Layer 4 load balancers work at the TCP level. They forward connections without understanding the application protocol inside those connections. That makes them powerful and general, but harder to explain visually when you are learning.

Layer 7 load balancers understand HTTP. They can inspect paths, headers, status codes, and methods. For a first build, HTTP is more approachable because you can test it with a browser, curl, and plain JSON endpoints.

So this project starts with an HTTP load balancer.

The client sends a request to the balancer:


client -> load balancer -> backend

The client does not need to know which backend actually handled the request. It only knows that it asked the load balancer for something and received a response.

That is the core idea of a reverse proxy.

The Shape of the Project

The repository is split into a few focused pieces:


cmd/loadbalancer      the main load balancer process

cmd/demo-backend     tiny backend servers for local testing

configs/demo.json    local demo configuration

internal/balancer    proxy, pool, health checks, stats, and UI

internal/config      JSON config loading and validation

The demo config tells the balancer where to listen and which backends to use:


{

  "listen_addr": ":8080",

  "backends": [

    "http://localhost:9001",

    "http://localhost:9002"

  ],

  "health_path": "/health",

  "health_interval": "2s",

  "request_timeout": "5s",

  "shutdown_timeout": "5s",

  "max_retries": 1,

  "max_replay_body_mib": 1

}

With that config, the load balancer listens on port 8080 and spreads traffic across two local demo servers on ports 9001 and 9002.

Backend Pool: The Heart of the Balancer

A load balancer needs a model of its upstream servers. In this project, each backend tracks:

The backend pool owns a list of these backends and decides which one should receive the next request.

The first algorithm is round robin. That means requests cycle through healthy backends in order:


request 1 -> backend 1

request 2 -> backend 2

request 3 -> backend 1

request 4 -> backend 2

The important detail is that unhealthy backends are skipped. If backend 2 is down, traffic should continue to backend 1 instead of failing every other request.

That gives us the first real load balancing behavior:


healthy:   backend 1, backend 2

traffic:   1, 2, 1, 2

  

backend 2 fails

  

healthy:   backend 1

traffic:   1, 1, 1, 1

Simple, visible, useful.

Reverse Proxying with the Go Standard Library

Go makes the proxy part surprisingly approachable because the standard library includes net/http/httputil.

The balancer uses httputil.NewSingleHostReverseProxy to forward a request to the selected backend. Around that, the project adds the behavior a learning load balancer should expose:

The response includes headers like:


X-Backend: http://localhost:9001

X-Load-Balanced-By: scratch-go

Those headers are especially useful for demos because they let the frontend and curl output show which backend handled each request.

Health Checks: Keeping Bad Backends Out of Rotation

A load balancer should not blindly send traffic to a backend that is unavailable.

This project runs an active health checker. Every few seconds, it calls each backend's /health endpoint. If the backend returns a successful status code, it stays healthy. If the request fails or returns an error status, that backend is marked unhealthy.

That creates a feedback loop:


health checker -> backend /health

backend OK     -> keep in rotation

backend fails  -> remove from rotation

backend recovers -> add back to rotation

The demo backend includes a /toggle route, which makes this easy to test without killing processes:


curl http://localhost:9001/toggle

When a backend becomes unhealthy, the balancer's /stats endpoint reflects that state, and the animated UI turns that backend red.

Timeouts and Retries

Without timeouts, a proxy can get stuck waiting on a slow backend. That is bad for clients and bad for the balancer.

The project configures request timeouts through Go's HTTP transport:

It also supports limited retries. If a proxy attempt fails before a response is written, the balancer can try another healthy backend.

Retries are intentionally conservative. They are helpful for simple GET-like demos, but in real systems you have to think carefully about whether retrying a request is safe. Retrying a payment request, for example, is very different from retrying a request for a static page.

For this project, retries are there to show the concept without pretending the problem is finished.

Observability: The /stats Endpoint

One of the most useful parts of the project is the /stats endpoint.

It returns JSON like:


{

  "algorithm": "round_robin",

  "total": 2,

  "healthy": 2,

  "backends": [

    {

      "url": "http://localhost:9001",

      "healthy": true,

      "requests": 12,

      "failures": 0,

      "health_failures": 0,

      "in_flight": 0

    }

  ]

}

This matters because infrastructure is much easier to understand when it can explain itself.

The stats endpoint powers the frontend, but it is also useful directly:


curl http://localhost:8080/stats

You can watch request counts rise, backends leave rotation, and recovered backends rejoin the pool.

The Animated Frontend

After the backend worked, I added a small frontend served by the load balancer itself at:


http://localhost:8080/ui/

The frontend is not just decorative. It sends real requests through the load balancer and reads real backend state from /stats.

It shows:

This turns the invisible behavior of a load balancer into something you can watch.

Clicking "Send" fires one request. Clicking "Burst" sends several. Leaving "Auto" on keeps traffic flowing so round robin becomes obvious.

When both backends are healthy, packets alternate between them. When one backend goes unhealthy, the animation skips it. That is the moment where load balancing stops being abstract.

Running It Locally

Start two demo backends:


go run ./cmd/demo-backend -addr :9001 -name backend-1

go run ./cmd/demo-backend -addr :9002 -name backend-2

Start the load balancer:


go run ./cmd/loadbalancer -config configs/demo.json

Then open:


http://localhost:8080/ui/

Or test with curl:


curl http://localhost:8080/hello

curl http://localhost:8080/hello

curl http://localhost:8080/stats

What This Project Teaches

The fun part of building a load balancer is that every feature points to a real systems idea.

Round robin teaches scheduling.

Health checks teach failure detection.

Timeouts teach resource protection.

Retries teach fault tolerance and the danger of repeated side effects.

Stats teach observability.

Graceful shutdown teaches lifecycle management.

The animated frontend teaches that tooling matters. If you can see a system move, you can understand it faster.

What I Would Add Next

This version is intentionally small, but it creates a good base for deeper experiments.

Good next steps would be:

Each one opens a different door into how production load balancers work.

Final Thought

The best way to understand infrastructure is often to build a tiny version of it yourself.

This load balancer is small enough to read, but real enough to behave like the thing it is teaching. It accepts requests, chooses healthy backends, proxies traffic, watches for failures, exposes stats, and shows the whole flow in an animated UI.

That is a pretty satisfying result for a "just for fun" project.