Frontend Labs
⋯

Day 36 — Connection Pools

Learning Objectives

  • Build a generic TCP connection pool — the foundation of your load balancer
  • Implement idle timeout and max pool size
  • Understand why connection pools dramatically reduce connection overhead

Key Concepts

Without pool: each request = DNS + TCP handshake + (TLS handshake) + request
              latency: 50ms DNS + 1ms TCP + 5ms TLS = 56ms overhead per request

With pool:    first request connects, subsequent requests reuse the connection
              latency: ~0ms overhead for subsequent requests
              trade-off: idle connections consume server-side resources

Pool design:
  Get(addr):  return idle connection or dial new one
  Put(conn):  return connection to pool for reuse
  Close():    close all idle connections
  Idle timer: close connections idle > threshold

Exercise: TCP Connection Pool

package main

import (
    "fmt"
    "net"
    "sync"
    "time"
)

type Pool struct {
    mu          sync.Mutex
    idle        map[string][]idleConn
    maxPerAddr  int
    idleTimeout time.Duration
    dialer      net.Dialer
}

type idleConn struct {
    conn    net.Conn
    idleSince time.Time
}

func NewPool(maxPerAddr int, idleTimeout time.Duration) *Pool {
    p := &Pool{
        idle:        make(map[string][]idleConn),
        maxPerAddr:  maxPerAddr,
        idleTimeout: idleTimeout,
        dialer:      net.Dialer{Timeout: 5 * time.Second},
    }
    go p.reaper()
    return p
}

// Get returns an idle connection or dials a new one
func (p *Pool) Get(addr string) (net.Conn, error) {
    p.mu.Lock()
    idles := p.idle[addr]
    for len(idles) > 0 {
        ic := idles[len(idles)-1]
        idles = idles[:len(idles)-1]
        p.idle[addr] = idles
        p.mu.Unlock()

        // Test if connection is still alive (1-byte read with immediate timeout)
        ic.conn.SetReadDeadline(time.Now())
        buf := make([]byte, 1)
        _, err := ic.conn.Read(buf)
        ic.conn.SetReadDeadline(time.Time{}) // clear deadline

        if err != nil && !isTimeout(err) {
            ic.conn.Close() // stale connection
            p.mu.Lock()
            idles = p.idle[addr]
            continue
        }

        return ic.conn, nil // healthy idle connection
    }
    p.mu.Unlock()

    // No idle connection — dial new
    return p.dialer.Dial("tcp", addr)
}

// Put returns a connection to the pool for reuse
func (p *Pool) Put(addr string, conn net.Conn) {
    p.mu.Lock()
    defer p.mu.Unlock()

    idles := p.idle[addr]
    if len(idles) >= p.maxPerAddr {
        conn.Close() // pool full — close excess
        return
    }
    p.idle[addr] = append(idles, idleConn{conn: conn, idleSince: time.Now()})
}

// reaper closes connections that have been idle too long
func (p *Pool) reaper() {
    ticker := time.NewTicker(30 * time.Second)
    defer ticker.Stop()
    for range ticker.C {
        p.mu.Lock()
        cutoff := time.Now().Add(-p.idleTimeout)
        for addr, idles := range p.idle {
            var live []idleConn
            for _, ic := range idles {
                if ic.idleSince.After(cutoff) {
                    live = append(live, ic)
                } else {
                    ic.conn.Close()
                }
            }
            p.idle[addr] = live
        }
        p.mu.Unlock()
    }
}

func (p *Pool) Close() {
    p.mu.Lock()
    defer p.mu.Unlock()
    for _, idles := range p.idle {
        for _, ic := range idles {
            ic.conn.Close()
        }
    }
    p.idle = make(map[string][]idleConn)
}

func isTimeout(err error) bool {
    ne, ok := err.(net.Error)
    return ok && ne.Timeout()
}

func main() {
    pool := NewPool(10, 30*time.Second)
    defer pool.Close()

    // Simulate 50 concurrent requests to the same backend
    var wg sync.WaitGroup
    for i := 0; i < 50; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // In a real proxy, replace with your actual backend addr
            conn, err := pool.Get("example.com:80")
            if err != nil {
                fmt.Printf("[%d] dial error: %v\n", id, err)
                return
            }
            // Use connection...
            pool.Put("example.com:80", conn) // return to pool
        }(i)
    }
    wg.Wait()
    fmt.Println("Done")
}

Real-World Context

http.Transport in Go's standard library is a connection pool. When you configure MaxIdleConnsPerHost: 100, that's the pool size. Your reverse proxy (Week 9) uses httputil.ReverseProxy which uses http.Transport under the hood. Building a pool from scratch shows you why those config options exist.


Generated from week5.md · 8 Oct 2026 16:36