Build a Load Balancer in Go (Step by Step)
In this tutorial, you'll learn about Build a Load Balancer in Go (Step by Step). We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build a round-robin load balancer in Go that distributes HTTP requests across multiple backend servers with automatic health checks and configurable backends via a YAML file.
What You'll Build
You'll write a Go program that sits between clients and a pool of backend servers. It accepts incoming HTTP requests, forwards each one to the next healthy backend in round-robin order, periodically pings backends to check their health, and removes unhealthy servers from the pool until they recover.
Why Load Balancers Matter
Load Balancing is the backbone of horizontal scaling. Every major web application — from e-commerce sites to streaming platforms — uses load balancers to distribute traffic, prevent any single server from being overwhelmed, and provide fault tolerance when servers fail. Understanding how they work at the code level helps you design resilient systems. At DodaTech, Load Balancing patterns keep Doda Browser's sync infrastructure available across multiple regions.
Prerequisites
- Go 1.21+ installed
- Basic understanding of HTTP protocol
- Familiarity with Go concurrency (goroutines)
Step 1: Project Setup
mkdir load-balancer && cd load-balancer
go mod init load-balancer
go get gopkg.in/yaml.v3
Create this structure:
load-balancer/
├── main.go # Entry point and server loop
├── balancer.go # Round-robin logic
├── health.go # Health check routines
├── config.yaml # Backend configuration
└── go.mod
Step 2: Configuration File
Backend servers should be configurable without recompiling. We'll use a YAML file to declare the pool:
# config.yaml
port: 8080
backends:
- url: http://localhost:9001
name: server-1
- url: http://localhost:9002
name: server-2
- url: http://localhost:9003
name: server-3
health_check:
interval: 5s
timeout: 2s
path: /health
The balancer listens on port 8080 and proxies traffic to three backends. Every 5 seconds it sends a GET request to each backend's /health endpoint.
Step 3: Load Balancer Core
The balancer struct holds the backend pool, a round-robin counter, and runs health checks in the background:
// balancer.go
package main
import (
"net/http"
"net/http/httputil"
"net/url"
"sync"
)
type Backend struct {
URL *url.URL
Name string
Healthy bool
Proxy *httputil.ReverseProxy
}
type Balancer struct {
backends []*Backend
current int
mu sync.Mutex
}
func NewBalancer(config *Config) *Balancer {
b := &Balancer{}
for _, be := range config.Backends {
u, _ := url.Parse(be.URL)
b.backends = append(b.backends, &Backend{
URL: u,
Name: be.Name,
Healthy: true,
Proxy: httputil.NewSingleHostReverseProxy(u),
})
}
return b
}
func (b *Balancer) Next() *Backend {
b.mu.Lock()
defer b.mu.Unlock()
n := len(b.backends)
for i := 0; i < n; i++ {
idx := (b.current + i) % n
if b.backends[idx].Healthy {
b.current = (idx + 1) % n
return b.backends[idx]
}
}
return nil
}
The Next() method walks through backends starting from the current position, wrapping around modulo N. If no healthy backend exists, it returns nil — the caller handles this as a 503 Service Unavailable.
Step 4: Health Checker
Health checks run in a separate Goroutine. They probe each backend at the configured interval and mark unhealthy servers as unavailable:
// health.go
package main
import (
"log"
"net/http"
"time"
)
func StartHealthChecks(b *Balancer, cfg *HealthCheckConfig) {
client := &http.Client{Timeout: cfg.Timeout}
for {
time.Sleep(cfg.Interval)
for _, be := range b.backends {
go func(backend *Backend) {
resp, err := client.Get(backend.URL.String() + cfg.Path)
if err != nil || resp.StatusCode != 200 {
if backend.Healthy {
log.Printf("Marking %s unhealthy", backend.Name)
backend.Healthy = false
}
} else {
if !backend.Healthy {
log.Printf("Marking %s healthy again", backend.Name)
backend.Healthy = false
}
backend.Healthy = true
resp.Body.Close()
}
}(be)
}
}
}
Each backend is checked concurrently inside the loop. If a backend was previously healthy and now fails, we log the transition. The same applies when it recovers.
Step 5: The Reverse Proxy Handler
When a client connects, the balancer picks a backend and forwards the request using httputil.ReverseProxy:
// main.go
package main
import (
"gopkg.in/yaml.v3"
"log"
"net/http"
"os"
"time"
)
type Config struct {
Port int `yaml:"port"`
Backends []BackendConfig `yaml:"backends"`
HealthCheck HealthCheckConfig `yaml:"health_check"`
}
type BackendConfig struct {
URL string `yaml:"url"`
Name string `yaml:"name"`
}
type HealthCheckConfig struct {
Interval time.Duration `yaml:"interval"`
Timeout time.Duration `yaml:"timeout"`
Path string `yaml:"path"`
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func main() {
cfg, err := loadConfig("config.yaml")
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
balancer := NewBalancer(cfg)
go StartHealthChecks(balancer, &cfg.HealthCheck)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
backend := balancer.Next()
if backend == nil {
http.Error(w, "No healthy backends", http.StatusServiceUnavailable)
return
}
log.Printf("Forwarding to %s (%s)", backend.Name, backend.URL)
backend.Proxy.ServeHTTP(w, r)
})
addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("Load balancer listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
When no healthy backends exist, the balancer returns HTTP 503. In production you might add a fallback "sorry" page or a static maintenance page.
Step 6: Starting the Backend Servers
For testing, create three simple backends that respond with their own name:
// backend.go (separate file, not part of the balancer package)
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Args[1]
name := os.Args[2]
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Response from %s", name)
})
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Printf("Backend %s listening on :%s", name, port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
Start three terminals:
go run backend.go 9001 server-1
go run backend.go 9002 server-2
go run backend.go 9003 server-3
Step 7: Running the Load Balancer
go run .
Test the balancer with curl:
for i in {1..6}; do curl -s http://localhost:8080/; echo; done
Expected output:
Response from server-1
Response from server-2
Response from server-3
Response from server-1
Response from server-2
Response from server-3
Requests rotate through the three backends in strict order. Kill one backend with Ctrl-C — after the next health check, the balancer stops sending traffic to it and logs a warning.
Architecture
flowchart LR
C[Clients] --> LB[Load Balancer :8080]
LB --> B1[Backend 1 :9001]
LB --> B2[Backend 2 :9002]
LB --> B3[Backend 3 :9003]
HC[Health Checker] --> B1
HC --> B2
HC --> B3
Config[config.yaml] --> LB
Common Errors
1. Backend URL missing scheme
If you write localhost:9001 without http://, url.Parse() interprets the hostname as a scheme and the proxy fails to connect. Always include the full URL with scheme: http://localhost:9001.
2. Health check timeout causes cascading failures
If the health check timeout is longer than the check interval, health checks pile up and goroutines leak. Ensure timeout < interval. A 2-second timeout with a 5-second interval is safe.
3. Port already in use
If you see bind: address already in use, another Process is running on the same port. Kill it or change the port in config.yaml. Use lsof -i :8080 to find the conflicting Process.
4. Reverse proxy modifies request headers incorrectly
httputil.ReverseProxy sets X-Forwarded-For and X-Real-Ip automatically. If your backend checks the Host header, it will see the balancer's host instead of the client's. Configure the proxy's Director func to preserve the original host.
5. Race Condition on the "current" counter
Without the mu sync.Mutex, two concurrent requests could read the same current value and both forward to the same backend. The mutex ensures atomic increments.
Practice Questions
1. What happens when all backends are unhealthy?
The Next() method returns nil, and the handler responds with HTTP 503 Service Unavailable. In production you'd serve a static fallback or redirect to a maintenance page.
2. How does round-robin differ from least-connections balancing? Round-robin cycles through backends in fixed order regardless of load. Least-connections sends each request to the backend with the fewest active connections, which handles variable request durations better.
3. Why do we check health in a separate Goroutine? Health checks use network I/O with timeouts. Running them synchronously in the request path would block every client request. A background Goroutine decouples probing from serving.
4. Challenge: Implement weighted round-robin
Add a weight: 3 field to each backend in the YAML config. A backend with weight 3 should receive three times as many requests as a backend with weight 1.
5. Challenge: Add active connection draining When a backend becomes unhealthy, keep serving its in-flight requests but stop sending new ones. Wait for the active connection count to reach zero before removing it entirely.
FAQ
Next Steps
- Add Docker Compose to orchestrate the balancer and backends as containers
- Study how NGINX implements Load Balancing for production-grade inspiration
- Combine with the Task Queue tutorial to distribute background work across workers
- Explore gRPC load balancing for high-performance microservice architectures
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro