Projects/Build a Load Balancer

Build a Load Balancer

Model request dispatch as an injected transport so selection, connection tracking, and health checks are all exactly testable with pinned sequences - no sockets, no flakiness. Every lesson is one concrete spec with exact values: round-robin wrapping from the last backend back to the first, weighted 3-to-1 cycling A,A,A,B, least-connections breaking a tie deterministically, power-of-two-choices taking the less loaded of two, ejection after exactly three consecutive failures, recovery after two good probes, an all-down pool returning a clear error instead of hanging, and a draining backend excluded from new work while its in-flight count still drains to zero.

25 lessonsSmall~20 min / lessonLoad balancingHealth checkingWeighted round-robin
The project

What you'll build over the next 25 lessons

Over 25 lessons you build a working load balancer from scratch, designed so every decision it makes is exactly testable. The trick is an injectable transport: instead of opening real sockets, the balancer dispatches each request through a function you supply that maps a chosen backend to a response. That keeps selection order, connection counts, and health-state transitions all deterministic - the same balancer core runs in any language, and every lesson pins an exact sequence, count, or state.

You start with a backend pool you can add to, remove from, and filter to the healthy members. Then you build the selection algorithms one per lesson - round-robin with its wrap-around cursor, weighted round-robin, seeded random, least-connections, power-of-two-choices, and sticky sessions - each with its exact dispatch order pinned. On top of that you track active connections through dispatch (up on the way in, down on completion, even on error), and drive a health-check state machine that ejects a backend after a run of failures, probes backends out of band, and brings one back after enough successes. The capstone routes a scripted request stream across backends that flap and drain, asserting the exact backend chosen for every request and that every connection count returns to zero.

This is a teaching-grade layer-7 balancer built around the classic algorithms in HAProxy, NGINX, and Envoy: it selects a healthy backend, tracks load, and reacts to health signals. It is honest about what it stops short of - the graded core dispatches through an injected transport rather than real sockets (the finalize pass adds a runnable TCP reverse proxy), it is single-goroutine with no locking, and it uses simple session-table stickiness rather than a consistent-hashing ring (that is its own separate project). That is the honest core that production proxies extend with real I/O, concurrency, and richer routing.

build-a-load-balancer / lesson-01.md
Lesson 01 · The backend pool

A backend and its status

A load balancer spreads work across a set of backends, so the first thing to model is one backend - an address it forwards to and whether it is currently up. Today you build that type and toggle its health.

The goal

Create a backend with an id and address that starts up, and can be marked down and up again.

Start here - the target
TO DO
Scenario: A backend tracks whether it is up
Givena backend created with NewBackend("A", "10.0.0.1:80")
Whenits status is queried
ThenID is "A", Addr is "10.0.0.1:80", and IsUp reports true
Andafter MarkDown it reports false, after MarkUp it reports true again, and a second backend created with NewBackend("B", "10.0.0.2:80") is up and unaffected
Background

Every load balancer sits in front of a group of backends - the real servers that do the work. A backend is mostly an address to forward a request to, plus a little state the balancer keeps about it. The most important piece of that state is whether the backend is healthy right now, because a balancer must never send new work to a server it believes is down.

Model the health as a small status value rather than a bare boolean. Today it only needs Up and Down, but later in the project a backend can also be Draining (finishing its in-flight work while taking no new requests), and an enum leaves room for that third state without a rewrite. Start every backend Up; the health-check chapter is where status starts changing on its own.

Make it work
// status is an enum so DRAINING can join UP and DOWN later
type Status int
const ( Up Status = iota; Down )
type Backend struct {
ID, Addr string
status Status
}
func NewBackend(id, addr string) *Backend { return &Backend{ID: id, Addr: addr, status: Up} }
func (b *Backend) IsUp() bool { return b.status == Up }
func (b *Backend) MarkDown() { b.status = Down }
func (b *Backend) MarkUp() { b.status = Up }
CheckpointDONE
You have a backend that carries an address and a toggleable health status. Commit and stop here.
Scope & extensions

Where this project stops - and where to go next

The balancing, health-checking, and routing logic is genuinely complete and runs end to end - the finalize pass wires the injected-transport core to real HTTP sockets in a runnable reverse proxy (cmd/lb) with live active health probes, failover, and a 503 when everything is down - but it is a minimal string-in, string-out proxy with no header, method, or status passthrough, the weighted round-robin schedule is frozen at construction, and a Backend is not synchronized for the concurrent traffic a real listener drives.

Extend it next
  • Add a mutex (or atomic fields) to Backend so the active-prober goroutine and concurrent request handlers cannot race on status, active count, and the failure/success streaks
  • Re-check that the chosen backend is still up between selection and dispatch, so a stale pick (notably from weighted round-robin's frozen schedule) never forwards to an already-down backend
  • Extend Request and Response to carry the method, headers, status code, and a streaming body for true reverse-proxy passthrough instead of a single string
  • Rebuild the weighted round-robin schedule when weights or membership change, and add smooth (interleaved) weighted round-robin so a 3-to-1 split arrives as A,A,B,A rather than A,A,A,B
  • Expose sticky sessions through the runnable proxy's algorithm flag, and add dynamic backend add/remove plus graceful shutdown for the live listener
  • Grow toward a consistent-hashing ring (its own project) so a key's backend barely moves when the pool changes, rather than the session-table stickiness used here
Recommended reading

Books & references that go deeper

  • The reference for real-world balancing: the balance algorithms (roundrobin, static-rr, leastconn, source, random), backend weights, and the check / rise / fall health-check parameters this project models directly.

  • The admin-level view of round-robin, weighted, least-connections, and hash-based methods, plus passive health checks (max_fails / fail_timeout) - the same mechanisms built here from first principles.

  • The Envoy author's overview of L4 vs L7 balancing, the load-balancing algorithm zoo, and active vs passive health checking - the best single orientation to the problem space.

  • Production semantics for the algorithms and the health-check state machine (active probing, outlier detection / passive ejection, healthy panic threshold) - read alongside the health-checking chapter.

  • The paper behind power-of-two-choices: why sampling two backends and taking the less loaded gives almost the balance of tracking every backend, at a fraction of the cost.

  • A short, intuitive writeup of why two random choices beats one, and where it beats even global least-connections under stale load information - the practical companion to the paper.