Go
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions and Methods
-
Section 4: Concurrency
-
Section 5: Packages and Tooling
-
Section 6: More Standard Library
-
Section 7: Building Services
-
Section 8: Advanced Go
-
Section 9: Go in the Cloud-Native Ecosystem
-
Section 10: Data Structures and Algorithms in Go
-
Section 11: Testing and Deployment
-
Section 12: Practical Projects
-
Section 13: More Standard Library Practice
-
Section 14: More Practice Projects
-
Section 15: Design Patterns in Go
-
Section 16: Interview Practice
-
Section 17: Package fmt In Depth
-
Section 18: Package strings and strconv
-
Section 19: Package os and io
-
Section 20: Package time
-
Section 21: Package sort and container
-
Section 22: Package encoding
-
Section 23: Package net/http In Depth
-
Section 24: Package context
-
Section 25: Package regexp and bytes
-
Section 26: Package errors In Depth
-
Section 27: Package crypto and hash
-
Section 28: Package flag and log
-
Section 29: Package sync In Depth
-
Section 30: More Practice Exercises
-
Section 31: Go Modules and Workspaces In Depth
-
Section 32: Generics Deep Dive (Go 1.18+)
-
Section 33: Testing Package In Depth
-
Section 34: More Interview and Whiteboard Practice
-
Section 35: Package math and unicode
-
Section 36: Package path and filepath
-
Section 37: Package database/sql
-
Section 38: Package text/template and html/template
-
Section 39: Package archive and compress
-
Section 40: Lower-Level net Package
-
Section 41: Package runtime and reflect
-
Section 42: Package embed and io/fs
-
Section 43: Go Assembly and CGO Basics
-
Section 44: Building CLIs and TUIs
-
Section 45: Go Performance Tuning
-
Section 46: More Real-World Projects
-
Section 47: Go in Production
-
Section 48: Go Design Patterns
-
Section 49: Go Interfaces Deep Dive
-
Section 50: Final Practice and Review
86: Building a Simple Load Balancer in Go
Wait, do I have to manually copy every HTTP header to the backend?
You might be tempted to use http.Get to fetch data from a backend and then write that response back to the client. Please, don't do that. You'll spend your entire weekend trying to figure out why your cookies are disappearing or why the Content-Type headers are slightly off.
In Go, the net/http/httputil package gives us ReverseProxy. It's a powerhouse. It takes an incoming request, modifies it slightly, sends it to the target, and streams the response back to the client. It handles the heavy lifting of header copying and connection management for you.
package main
import (
"net/http"
"net/http/httputil"
"net/url"
)
func proxyRequest(target string, w http.ResponseWriter, r *http.Request) {
remote, _ := url.Parse(target)
proxy := httputil.NewSingleHostReverseProxy(remote)
// The proxy handles the request and writes the response to 'w'
proxy.ServeHTTP(w, r)
}
I've used this pattern in production dozens of times. It's clean, efficient, and lets you focus on the logic of where to send the traffic, rather than how to move the bytes.
How do I make sure the requests are spread evenly?
The simplest way to handle this is "Round Robin." You keep a list of your backend servers and an index. Every time a request comes in, you send it to the current index and then increment it.
But here's the catch: since your load balancer will be handling many requests concurrently in different goroutines, you can't just use i++. That's a race condition waiting to happen. I prefer using the sync/atomic package for this because it's significantly faster than a mutex for a simple counter.
import (
"net/http"
"sync/atomic"
)
type LoadBalancer struct {
targets []string
current uint64
}
func (lb *LoadBalancer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Atomically increment and get the index
idx := atomic.AddUint64(&lb.current, 1)
target := lb.targets[idx%uint64(len(lb.targets))]
// Now we use the proxy logic from before
remote, _ := url.Parse(target)
proxy := httputil.NewSingleHostReverseProxy(remote)
proxy.ServeHTTP(w, r)
}
What happens if one of my backend servers dies?
If you use the code above and one server goes down, your load balancer will keep sending 1/3 of your traffic (assuming three servers) into a black hole. That's a bad user experience. You need a way to "mark" servers as unhealthy.
The most robust way is to run a background goroutine that pings each server every few seconds. I usually create a Backend struct that tracks the status. If the health check fails, we flip a boolean and the ServeHTTP method skips that server.
type Backend struct {
URL *url.URL
Alive bool
mux sync.RWMutex
ReverseProxy *httputil.ReverseProxy
}
func (b *Backend) SetAlive(alive bool) {
b.mux.Lock()
b.Alive = alive
b.mux.Unlock()
}
func (b *Backend) IsAlive() bool {
b.mux.RLock()
defer b.mux.RUnlock()
return b.Alive
}
// In your health check loop:
func healthCheck() {
for _, b := range backends {
res, err := http.Get(b.URL.String())
if err != nil || res.StatusCode != http.StatusOK {
b.SetAlive(false)
} else {
b.SetAlive(true)
}
}
}
One tip: keep your health check timeout short. You don't want your monitoring routine hanging for 30 seconds while your users are getting 502 errors.
📋 Practical Task
Implementing a Dynamic Server Registry for the Load Balancer
Currently, our load balancer uses a hardcoded list of servers. In a real environment, servers spin up and down constantly.
Your Task: Modify the LoadBalancer struct to include a method called AddBackend(url string) and RemoveBackend(url string). These methods must be thread-safe (use a sync.RWMutex) so that you can add or remove backend servers from the pool while the load balancer is actively routing live traffic without causing a panic.
Verify your implementation by starting the balancer, adding a backend, sending a few requests, removing that backend, and ensuring the balancer gracefully switches to the remaining servers.
There are no comments for now.