· 19 min read

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:

OSI Layer 4 View

Layer 7 View

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

OSI Layer 4 View

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.

Layer 4 Load Balancing Flow

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.

NAT-based L4 load balancing

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?

Direct Server Return (DSR)

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

Layer 4 Load Balancing 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

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

Layer 7 Load Balancing 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.

Layer 7 SSL Termination

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:

Hybrid Architecture

  • 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