Skip to content

Build a Load Balancer in Go (Step by Step)

DodaTech Updated 2026-06-21 8 min read

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

What is the difference between a reverse proxy and a load balancer?

A reverse proxy forwards client requests to a single backend server. A load balancer distributes requests across multiple backends. In practice, most load balancers (like NGINX, HAProxy) are also reverse proxies.

Can I use this balancer for HTTPS traffic?

Yes. Replace http.ListenAndServe with http.ListenAndServeTLS and provide certificate and key file paths. The reverse proxy layer also supports TLS between the balancer and backends.

Does round-robin work for WebSocket connections?

Go's httputil.ReverseProxy handles HTTP upgrades, including WebSocket handshakes. Once the upgrade completes, the proxy tunnels raw bytes bidirectionally. The round-robin selection happens at connection time.

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