The Only Guide You'd Ever Need for Load Balancers - 8
Layer 4 vs Layer 7 Load Balancing - OSI Model Deep Dive
Welcome back. If you’re coming from part 7, you now have a load balancer with consistent hashing, cookie-based persistence, and graceful draining. Cool kids stuff.
But here’s something we’ve been doing without really talking about it: we’ve been mixing two very different approaches to load balancing. Sometimes we just forward raw TCP bytes (Layer 4), and sometimes we parse HTTP requests and inspect headers (Layer 7). But they’re fundamentally very different approaches, though. Understanding when to use each is important for building systems that actually perform well.
In this part, we’re going to dive deep into the OSI model (don’t worry, I’ll make it painless), understand what Layer 4 and Layer 7 load balancing actually mean, and build implementations of both.
The OSI Model
Before we talk about Layer 4 and Layer 7, let’s quickly cover what these “layers” even are.
The OSI (Open Systems Interconnection) model is a conceptual framework that describes how data moves through a network. It has 7 layers, each with a specific job.
┌─────────────────────────────────────────────────────────────────┐
│ │
│ THE OSI MODEL (from bottom to top) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Layer 7: APPLICATION │ │
│ │ What you interact with: HTTP, FTP, SMTP, DNS │ │
│ │ "I want to GET /profile" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↑ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Layer 6: PRESENTATION │ │
│ │ Encryption, compression, encoding │ │
│ │ SSL/TLS lives here (kinda) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↑ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Layer 5: SESSION │ │
│ │ Managing connections between applications │ │
│ │ (Honestly, nobody talks about this layer) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↑ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Layer 4: TRANSPORT ← HERE! │ │
│ │ TCP, UDP - ports, reliable delivery │ │
│ │ "Send this to port 80, make sure it arrives" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↑ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Layer 3: NETWORK │ │
│ │ IP addresses, routing between networks │ │
│ │ "This packet goes to 192.168.1.10" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↑ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Layer 2: DATA LINK │ │
│ │ MAC addresses, switches, local network │ │
│ │ Ethernet frames │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↑ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Layer 1: PHYSICAL │ │
│ │ Actual wires, radio waves, electrical signals │ │
│ │ The actual 1s and 0s on the wire │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
For load balancing, we care about two layers:
- Layer 4 (Transport): TCP/UDP, ports
- Layer 7 (Application): HTTP, the actual request content
Everything else is kind of irrelevant for us right now.
What Does Each Layer See?
This is the key insight. Different layers have access to different information.
Layer 4 View
At Layer 4, the load balancer sees:

Layer 7 View
At Layer 7, the load balancer parses the application protocol and sees everything:

This difference in visibility is everything. It determines what kind of routing decisions you can make.
Layer 4 Load Balancing
Let’s start with Layer 4. This is the simpler approach.
How It Works
A Layer 4 load balancer operates at the TCP/UDP level. It doesn’t understand what’s inside the packets, it just forwards them based on IP addresses and ports.

The L4 LB never looks inside the HTTP request. It just forwards packets based on the TCP connection.
NAT-Based Load Balancing
The most common L4 approach is NAT (Network Address Translation). The load balancer rewrites packet headers to redirect traffic.

The LB keeps a NAT table to map responses back correctly.
Direct Server Return (DSR)
What if the backend server could respond directly to the client without going through the load balancer?

Benefits:
- LB handles less traffic (responses are usually bigger)
- Lower latency for responses
- LB can handle more connections
DSR is common in high-performance L4 load balancers. The response (which is usually much larger than the request) goes directly to the client. The load balancer only handles the relatively small request packets.
L4 Implementation
Let’s look at a clean Layer 4 implementation. This is essentially what we built in parts 3-4, but let’s make it explicit:
package main
import (
"fmt"
"io"
"log"
"net"
"sync"
"sync/atomic"
)
type L4LoadBalancer struct {
host string
port int
backends []*Backend
current uint64
mux sync.RWMutex
}
func NewL4LoadBalancer(host string, port int) *L4LoadBalancer {
return &L4LoadBalancer{
host: host,
port: port,
backends: make([]*Backend, 0),
}
}
func (lb *L4LoadBalancer) AddBackend(host string, port int) {
lb.mux.Lock()
defer lb.mux.Unlock()
lb.backends = append(lb.backends, NewBackend(host, port))
}
func (lb *L4LoadBalancer) getNextBackend() *Backend {
lb.mux.RLock()
defer lb.mux.RUnlock()
if len(lb.backends) == 0 {
return nil
}
idx := atomic.AddUint64(&lb.current, 1) - 1
return lb.backends[idx%uint64(len(lb.backends))]
}
func (lb *L4LoadBalancer) Start() error {
addr := fmt.Sprintf("%s:%d", lb.host, lb.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer listener.Close()
log.Printf("[L4 LB] Started on %s", addr)
for {
clientConn, err := listener.Accept()
if err != nil {
log.Printf("[L4 LB] Accept error: %v", err)
continue
}
go lb.handleConnection(clientConn)
}
}
func (lb *L4LoadBalancer) handleConnection(clientConn net.Conn) {
defer clientConn.Close()
backend := lb.getNextBackend()
if backend == nil {
log.Printf("[L4 LB] No backends available")
return
}
backendConn, err := net.Dial("tcp", backend.Address())
if err != nil {
log.Printf("[L4 LB] Backend connection failed: %v", err)
return
}
defer backendConn.Close()
clientAddr := clientConn.RemoteAddr().String()
log.Printf("[L4 LB] %s → %s", clientAddr, backend.Address())
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
io.Copy(backendConn, clientConn)
}()
go func() {
defer wg.Done()
io.Copy(clientConn, backendConn)
}()
wg.Wait()
}
Notice what’s NOT there:
- No HTTP parsing
- No header inspection
- No URL routing
- No cookie handling
Just raw byte forwarding.
L4 Characteristics

USE CASES:
- Database load balancing (MySQL, PostgreSQL)
- Any non-HTTP TCP service
- When you need raw speed
- When you want end-to-end encryption
- Simple HTTP when content routing isn’t needed
Layer 7 Load Balancing
Now let’s look at Layer 7. This is where things get interesting (and slower, but more powerful).
How It Works
A Layer 7 load balancer actually understands the application protocol. For HTTP, it parses the request, inspects headers, and can make intelligent routing decisions.
LAYER 7 LOAD BALANCING FLOW

Content-Based Routing
The killer feature of L7 is content-based routing. You can route requests based on what’s IN them.
URL Path Routing
┌─────────────────────────────────────────────────────────────────┐
│ │
│ URL PATH ROUTING: │
│ │
│ L7 Load Balancer │
│ │ │
│ │ │
│ ┌──────────────────────┼──────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ /api/* │ │/static/*│ │ /* │ │
│ │ │ │ │ │ │ │
│ │ API │ │ CDN/ │ │ Web │ │
│ │ Servers │ │ Static │ │ Servers │ │
│ │ │ │ Servers │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Rules: │
│ - GET /api/users/123 → API server pool │
│ - GET /static/logo.png → Static file servers (or CDN) │
│ - GET /about → Web server pool │
│ - POST /api/login → API server pool │
│ │
└─────────────────────────────────────────────────────────────────┘
Host Header Routing (Virtual Hosts)
┌─────────────────────────────────────────────────────────────────┐
│ │
│ HOST HEADER ROUTING: │
│ │
│ All requests come to same IP, but different Host headers: │
│ │
│ L7 Load Balancer │
│ (203.0.113.50:80) │
│ │ │
│ │ │
│ ┌──────────────────────┼──────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Host: │ │ Host: │ │ Host: │ │
│ │ api.* │ │ www.* │ │ admin.* │ │
│ │ │ │ │ │ │ │
│ │ API │ │ Website │ │ Admin │ │
│ │ Servers │ │ Servers │ │ Servers │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ Rules: │
│ - Host: api.wingmandating.com → API servers │
│ - Host: www.wingmandating.com → Website servers │
│ - Host: admin.wingmandating.com → Admin servers │
│ │
│ One load balancer, multiple "virtual" sites │
│ │
└─────────────────────────────────────────────────────────────────┘
Header-Based Routing
┌─────────────────────────────────────────────────────────────────┐
│ │
│ HEADER-BASED ROUTING: │
│ │
│ Route based on custom headers: │
│ │
│ X-API-Version: v2 │
│ ├── Route to v2 API servers │
│ │
│ X-API-Version: v1 │
│ ├── Route to v1 API servers (legacy) │
│ │
│ Accept-Language: es │
│ ├── Route to Spanish content servers │
│ │
│ X-Priority: high │
│ ├── Route to premium/fast server pool │
│ │
│ User-Agent: *Mobile* │
│ ├── Route to mobile-optimized servers │
│ │
└─────────────────────────────────────────────────────────────────┘
L7 Implementation
Let’s build a proper Layer 7 load balancer with content-based routing:
package main
import (
"bufio"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
)
type Route struct {
Type string
Match string
MatchType string
Backends []*Backend
current uint64
}
type L7LoadBalancer struct {
host string
port int
routes []*Route
defaultBackends []*Backend
mux sync.RWMutex
}
func NewL7LoadBalancer(host string, port int) *L7LoadBalancer {
return &L7LoadBalancer{
host: host,
port: port,
routes: make([]*Route, 0),
}
}
func (lb *L7LoadBalancer) AddPathRoute(pathPrefix string, backends []*Backend) {
lb.mux.Lock()
defer lb.mux.Unlock()
route := &Route{
Type: "path",
Match: pathPrefix,
MatchType: "prefix",
Backends: backends,
}
lb.routes = append(lb.routes, route)
log.Printf("[L7 LB] Added route: path prefix '%s' → %d backends", pathPrefix, len(backends))
}
func (lb *L7LoadBalancer) AddHostRoute(hostname string, backends []*Backend) {
lb.mux.Lock()
defer lb.mux.Unlock()
route := &Route{
Type: "host",
Match: hostname,
MatchType: "exact",
Backends: backends,
}
lb.routes = append(lb.routes, route)
log.Printf("[L7 LB] Added route: host '%s' → %d backends", hostname, len(backends))
}
func (lb *L7LoadBalancer) AddHeaderRoute(headerName, headerValue string, backends []*Backend) {
lb.mux.Lock()
defer lb.mux.Unlock()
route := &Route{
Type: "header:" + headerName,
Match: headerValue,
MatchType: "exact",
Backends: backends,
}
lb.routes = append(lb.routes, route)
log.Printf("[L7 LB] Added route: header '%s: %s' → %d backends", headerName, headerValue, len(backends))
}
func (lb *L7LoadBalancer) SetDefaultBackends(backends []*Backend) {
lb.mux.Lock()
defer lb.mux.Unlock()
lb.defaultBackends = backends
}
func (lb *L7LoadBalancer) findBackend(req *http.Request) *Backend {
lb.mux.RLock()
defer lb.mux.RUnlock()
for _, route := range lb.routes {
var matches bool
switch route.Type {
case "path":
matches = strings.HasPrefix(req.URL.Path, route.Match)
case "host":
host := req.Host
if colonIdx := strings.Index(host, ":"); colonIdx != -1 {
host = host[:colonIdx]
}
matches = (host == route.Match)
default:
if strings.HasPrefix(route.Type, "header:") {
headerName := strings.TrimPrefix(route.Type, "header:")
headerValue := req.Header.Get(headerName)
matches = (headerValue == route.Match)
}
}
if matches {
if len(route.Backends) == 0 {
continue
}
idx := atomic.AddUint64(&route.current, 1) - 1
return route.Backends[idx%uint64(len(route.Backends))]
}
}
if len(lb.defaultBackends) == 0 {
return nil
}
return lb.defaultBackends[0]
}
func (lb *L7LoadBalancer) Start() error {
addr := fmt.Sprintf("%s:%d", lb.host, lb.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer listener.Close()
log.Printf("[L7 LB] Started on %s", addr)
for {
clientConn, err := listener.Accept()
if err != nil {
log.Printf("[L7 LB] Accept error: %v", err)
continue
}
go lb.handleConnection(clientConn)
}
}
func (lb *L7LoadBalancer) handleConnection(clientConn net.Conn) {
defer clientConn.Close()
reader := bufio.NewReader(clientConn)
req, err := http.ReadRequest(reader)
if err != nil {
log.Printf("[L7 LB] Failed to parse request: %v", err)
return
}
backend := lb.findBackend(req)
if backend == nil {
clientConn.Write([]byte("HTTP/1.1 503 Service Unavailable\r\n\r\nNo backend available"))
return
}
clientAddr := clientConn.RemoteAddr().String()
log.Printf("[L7 LB] %s %s %s → %s",
clientAddr, req.Method, req.URL.Path, backend.Address())
backendConn, err := net.Dial("tcp", backend.Address())
if err != nil {
log.Printf("[L7 LB] Backend connection failed: %v", err)
clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\nBackend unavailable"))
return
}
defer backendConn.Close()
clientIP, _, _ := net.SplitHostPort(clientAddr)
req.Header.Set("X-Forwarded-For", clientIP)
req.Header.Set("X-Real-IP", clientIP)
req.Header.Set("X-Load-Balancer", "wingman-lb-7")
req.Write(backendConn)
backendReader := bufio.NewReader(backendConn)
resp, err := http.ReadResponse(backendReader, req)
if err != nil {
log.Printf("[L7 LB] Failed to read backend response: %v", err)
return
}
resp.Header.Set("X-Backend-Server", backend.Address())
resp.Write(clientConn)
}
Using the L7 Load Balancer
func main() {
lb := NewL7LoadBalancer("0.0.0.0", 8080)
apiBackends := []*Backend{
NewBackend("127.0.0.1", 8081),
NewBackend("127.0.0.1", 8082),
}
staticBackends := []*Backend{
NewBackend("127.0.0.1", 8083),
}
webBackends := []*Backend{
NewBackend("127.0.0.1", 8084),
NewBackend("127.0.0.1", 8085),
}
adminBackends := []*Backend{
NewBackend("127.0.0.1", 8086),
}
v2Backends := []*Backend{
NewBackend("127.0.0.1", 8087),
}
lb.AddPathRoute("/api/", apiBackends)
lb.AddPathRoute("/static/", staticBackends)
lb.AddHostRoute("admin.wingmandating.com", adminBackends)
lb.AddHeaderRoute("X-API-Version", "v2", v2Backends)
lb.SetDefaultBackends(webBackends)
log.Println("[L7 LB] Routing rules:")
log.Println(" /api/* → API servers (8081, 8082)")
log.Println(" /static/* → Static servers (8083)")
log.Println(" admin.* → Admin servers (8086)")
log.Println(" X-API-Version: v2 → v2 API (8087)")
log.Println(" default → Web servers (8084, 8085)")
if err := lb.Start(); err != nil {
log.Fatal(err)
}
}
Request/Response Modification
One of the powerful features of L7 is the ability to modify requests and responses as they pass through.
┌─────────────────────────────────────────────────────────────────┐
│ │
│ REQUEST MODIFICATIONS (L7 can do): │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ADD HEADERS: │ │
│ │ - X-Forwarded-For: 192.168.1.100 (original client IP) │ │
│ │ - X-Forwarded-Proto: https (original protocol) │ │
│ │ - X-Real-IP: 192.168.1.100 │ │
│ │ - X-Request-ID: uuid-for-tracing │ │
│ │ │ │
│ │ MODIFY HEADERS: │ │
│ │ - Host: internal-api.local (rewrite for internal) │ │
│ │ │ │
│ │ REWRITE URL: │ │
│ │ - /v1/users → /api/v1/users (add prefix) │ │
│ │ - /old-path → /new-path (redirect internally) │ │
│ │ │ │
│ │ REMOVE HEADERS: │ │
│ │ - Remove sensitive headers before forwarding │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ RESPONSE MODIFICATIONS (L7 can do): │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ADD HEADERS: │ │
│ │ - Strict-Transport-Security (HSTS) │ │
│ │ - X-Content-Type-Options: nosniff │ │
│ │ - X-Backend-Server: server-2 (for debugging) │ │
│ │ │ │
│ │ MODIFY HEADERS: │ │
│ │ - Set-Cookie: add Secure flag, domain rewrite │ │
│ │ │ │
│ │ REMOVE HEADERS: │ │
│ │ - Remove internal headers (X-Internal-*) │ │
│ │ - Remove Server header (hide backend info) │ │
│ │ │ │
│ │ COMPRESS RESPONSE: │ │
│ │ - gzip/brotli compression │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
L7 Characteristics

USE CASES:
- Content-based routing (/api vs /static)
- A/B testing and canary deployments
- API versioning
- Authentication/authorization at edge
- Request/response modification
- SSL termination
- Compression
- Caching
SSL/TLS Termination
This is a huge topic, but let’s cover the basics since it’s one of the main reasons to use L7 load balancing.
The Problem with SSL and L4
With Layer 4 load balancing, SSL/TLS traffic is just encrypted bytes. The load balancer can’t see inside:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ L4 + SSL = BLIND LOAD BALANCING: │
│ │
│ Client L4 LB Backend │
│ │ │ │ │
│ │─── Encrypted blob ────►│─── Encrypted blob ───►│ │
│ │ (TLS handshake) │ (just forwards) │ │
│ │ │ │ │
│ │─── Encrypted blob ────►│─── Encrypted blob ───►│ │
│ │ (HTTP GET inside) │ (can't see it) │ │
│ │ │ │ │
│ │◄── Encrypted blob ─────│◄── Encrypted blob ────│ │
│ │ (HTTP response) │ (can't see it) │ │
│ │
└─────────────────────────────────────────────────────────────────┘
L4 LB can ONLY route based on:
- IP address
- Port number (443 = HTTPS, probably)
- Nothing else
Can’t do:
- URL path routing
- Host header routing
- Cookie inspection
- ANY content-based routing
SSL Termination at L7
The solution: terminate SSL at the load balancer. The LB decrypts the traffic, inspects it, makes routing decisions, then either re-encrypts or sends unencrypted to backends.

Benefits:
- Full content visibility
- Content-based routing works
- Can add security headers
- Backend servers don’t need SSL certs
- Centralized certificate management
Drawback:
- Traffic between LB and backend is unencrypted (well…unless you re encrypt it)
Performance Comparison
Let’s talk numbers. How much does L7 cost vs L4?
┌─────────────────────────────────────────────────────────────────┐
│ │
│ PERFORMANCE COMPARISON (approx): │
│ │
│ Metric │ Layer 4 │ Layer 7 │
│ ────────────────────┼──────────────┼───────────────────── │
│ Requests/sec │ 1,000,000+ │ 100,000-500,000 │
│ (single core) │ │ │
│ │ │ │
│ Latency added │ ~0.1-0.5ms │ ~1-5ms │
│ │ │ │
│ Memory per conn │ ~1-2 KB │ ~10-50 KB │
│ │ │ │
│ CPU usage │ Low │ Medium-High │
│ │ │ │
│ With SSL term │ N/A (can't) │ Even more CPU │
│ │ │ │
│ ────────────────────┼──────────────┼───────────────────── │
│ │
│ Why L7 is slower: │
│ - Must parse HTTP protocol │
│ - Must buffer entire request (sometimes) │
│ - More memory allocation │
│ - String operations for header matching │
│ - SSL termination is CPU-intensive │
│ │
│ Why L4 is faster: │
│ - Just forwards packets │
│ - No parsing, no buffering │
│ - Can use kernel-level optimizations │
│ - Less memory, less CPU │
│ │
└─────────────────────────────────────────────────────────────────┘
When Does Performance Matter?
Honestly? For most applications, L7 performance is fine. The extra millisecond or two doesn’t matter when your application takes 50ms to respond.
L4 performance matters when:
- You’re handling millions of connections
- Super low latency is needed (gaming, trading)
- You’re load balancing non HTTP protocols at scale
- You’re throughput maxxing I guess
Hybrid Architectures
In the real world, you often use both L4 and L7. Here’s a common pattern:

- L4 distributes load across multiple L7 instances
- If one L7 LB dies, L4 routes around it
- win win for everyone
Another Pattern: L4 for Non-HTTP, L7 for HTTP
┌─────────────────────────────────────────────────────────────────┐
│ │
│ PROTOCOL-BASED SEPARATION: │
│ │
│ Internet │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ │ │ │
│ Port 80/443 Port 3306 │
│ (HTTP) (MySQL) │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ L7 Load │ │ L4 Load │ │
│ │ Balancer │ │ Balancer │ │
│ │ │ │ │ │
│ │ - Path routing │ │ - Round robin │ │
│ │ - Host routing │ │ - No parsing │ │
│ │ - SSL term │ │ - Fast forward │ │
│ └────────────────┘ └────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ Web Servers │ │ MySQL Replicas │ │
│ └────────────────┘ └────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Decision Tree: L4 vs L7
Here’s how to decide:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ DECISION TREE: L4 vs L7 │
│ │
│ START │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ Is it HTTP/HTTPS │ │
│ │ traffic? │ │
│ └──────────────────────┘ │
│ │ │ │
│ Yes No │
│ │ │ │
│ ▼ └─────────────────┐ │
│ ┌─────────────────────┐ │ │
│ │ Do you need: │ │ │
│ │ - URL routing │ │ │
│ │ - Host routing │ │ │
│ │ - Header routing │ │ │
│ │ - Cookie routing │ │ │
│ │ - SSL termination │ │ │
│ │ - Request modify │ │ │
│ └─────────────────────┘ │ │
│ │ │ │ │
│ Yes No │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ L7 │ │ L4 │ │ L4 │ │
│ │ (smart │ │ (fast │ │ (only │ │
│ │ routing) │ │ forward) │ │ option) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────┘
Complete Implementation: Dual-Mode Load Balancer
Let’s build a load balancer that can operate in either L4 or L7 mode:
package main
import (
"bufio"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
)
type Backend struct {
Host string
Port int
}
func NewBackend(host string, port int) *Backend {
return &Backend{Host: host, Port: port}
}
func (b *Backend) Address() string {
return fmt.Sprintf("%s:%d", b.Host, b.Port)
}
// ============== L4 LOAD BALANCER ==============
type L4LoadBalancer struct {
host string
port int
backends []*Backend
current uint64
}
func NewL4LoadBalancer(host string, port int, backends []*Backend) *L4LoadBalancer {
return &L4LoadBalancer{
host: host,
port: port,
backends: backends,
}
}
func (lb *L4LoadBalancer) getNextBackend() *Backend {
if len(lb.backends) == 0 {
return nil
}
idx := atomic.AddUint64(&lb.current, 1) - 1
return lb.backends[idx%uint64(len(lb.backends))]
}
func (lb *L4LoadBalancer) Start() error {
addr := fmt.Sprintf("%s:%d", lb.host, lb.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer listener.Close()
log.Printf("[L4] Started on %s with %d backends", addr, len(lb.backends))
for {
conn, err := listener.Accept()
if err != nil {
continue
}
go lb.handleConnection(conn)
}
}
func (lb *L4LoadBalancer) handleConnection(clientConn net.Conn) {
defer clientConn.Close()
backend := lb.getNextBackend()
if backend == nil {
return
}
backendConn, err := net.Dial("tcp", backend.Address())
if err != nil {
return
}
defer backendConn.Close()
log.Printf("[L4] %s → %s", clientConn.RemoteAddr(), backend.Address())
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
io.Copy(backendConn, clientConn)
}()
go func() {
defer wg.Done()
io.Copy(clientConn, backendConn)
}()
wg.Wait()
}
// ============== L7 LOAD BALANCER ==============
type Route struct {
PathPrefix string
Host string
Backends []*Backend
current uint64
}
type L7LoadBalancer struct {
host string
port int
routes []*Route
defaultBackends []*Backend
defaultCurrent uint64
mux sync.RWMutex
}
func NewL7LoadBalancer(host string, port int) *L7LoadBalancer {
return &L7LoadBalancer{
host: host,
port: port,
routes: make([]*Route, 0),
}
}
func (lb *L7LoadBalancer) AddPathRoute(prefix string, backends []*Backend) {
lb.mux.Lock()
defer lb.mux.Unlock()
lb.routes = append(lb.routes, &Route{PathPrefix: prefix, Backends: backends})
log.Printf("[L7] Route added: %s → %d backends", prefix, len(backends))
}
func (lb *L7LoadBalancer) AddHostRoute(host string, backends []*Backend) {
lb.mux.Lock()
defer lb.mux.Unlock()
lb.routes = append(lb.routes, &Route{Host: host, Backends: backends})
log.Printf("[L7] Route added: Host '%s' → %d backends", host, len(backends))
}
func (lb *L7LoadBalancer) SetDefaultBackends(backends []*Backend) {
lb.defaultBackends = backends
}
func (lb *L7LoadBalancer) findBackend(req *http.Request) *Backend {
lb.mux.RLock()
defer lb.mux.RUnlock()
for _, route := range lb.routes {
match := false
if route.PathPrefix != "" && strings.HasPrefix(req.URL.Path, route.PathPrefix) {
match = true
}
if route.Host != "" {
host := req.Host
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
if host == route.Host {
match = true
}
}
if match && len(route.Backends) > 0 {
idx := atomic.AddUint64(&route.current, 1) - 1
return route.Backends[idx%uint64(len(route.Backends))]
}
}
if len(lb.defaultBackends) > 0 {
idx := atomic.AddUint64(&lb.defaultCurrent, 1) - 1
return lb.defaultBackends[idx%uint64(len(lb.defaultBackends))]
}
return nil
}
func (lb *L7LoadBalancer) Start() error {
addr := fmt.Sprintf("%s:%d", lb.host, lb.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer listener.Close()
log.Printf("[L7] Started on %s", addr)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
go lb.handleConnection(conn)
}
}
func (lb *L7LoadBalancer) handleConnection(clientConn net.Conn) {
defer clientConn.Close()
reader := bufio.NewReader(clientConn)
req, err := http.ReadRequest(reader)
if err != nil {
return
}
backend := lb.findBackend(req)
if backend == nil {
clientConn.Write([]byte("HTTP/1.1 503 Service Unavailable\r\n\r\n"))
return
}
log.Printf("[L7] %s %s → %s", req.Method, req.URL.Path, backend.Address())
backendConn, err := net.Dial("tcp", backend.Address())
if err != nil {
clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
return
}
defer backendConn.Close()
clientIP, _, _ := net.SplitHostPort(clientConn.RemoteAddr().String())
req.Header.Set("X-Forwarded-For", clientIP)
req.Header.Set("X-Real-IP", clientIP)
req.Write(backendConn)
backendReader := bufio.NewReader(backendConn)
resp, err := http.ReadResponse(backendReader, req)
if err != nil {
return
}
resp.Header.Set("X-Backend", backend.Address())
resp.Write(clientConn)
}
func main() {
mode := flag.String("mode", "l7", "Load balancer mode: l4 or l7")
port := flag.Int("port", 8080, "Listen port")
flag.Parse()
backends := []*Backend{
NewBackend("127.0.0.1", 8081),
NewBackend("127.0.0.1", 8082),
NewBackend("127.0.0.1", 8083),
}
apiBackends := []*Backend{
NewBackend("127.0.0.1", 8084),
NewBackend("127.0.0.1", 8085),
}
switch *mode {
case "l4":
log.Println("Starting in Layer 4 mode")
lb := NewL4LoadBalancer("0.0.0.0", *port, backends)
if err := lb.Start(); err != nil {
log.Fatal(err)
}
case "l7":
log.Println("Starting in Layer 7 mode")
lb := NewL7LoadBalancer("0.0.0.0", *port)
lb.AddPathRoute("/api/", apiBackends)
lb.SetDefaultBackends(backends)
if err := lb.Start(); err != nil {
log.Fatal(err)
}
default:
log.Fatalf("Unknown mode: %s", *mode)
}
}
Testing
# start in L4 mode
go run main.go -mode l4 -port 8080
# start in L7 mode
go run main.go -mode l7 -port 8080
# test L7 path routing
curl http://localhost:8080/api/users # goes to api backends
curl http://localhost:8080/home # goes to default backends
What Now?
We now understand the fundamental difference between L4 and L7 load balancing. Our load balancer can operate at both layers.
In the next part, I’ll tackle Connection Management and Pooling. Right now, we create a new backend connection for every client request. That’s wasteful. We’ll learn about connection pooling, keep-alive, and how to efficiently manage thousands of connections.
As always, hit me up on X / Twitter if you have questions or want to discuss literally anything (even non techy stuff, I love taking to people).
See you in part 9 <3