DEV Community

Bar Dror
Bar Dror

Posted on Originally published at codebeneath.com

HTTP Keep-Alive and Connection Pooling, Measured

Originally published on Code Beneath.

When you make an HTTP request without keep-alive, the client opens a fresh TCP connection, performs a three-way handshake, sends the request, receives the response, and closes the connection. If you're making dozens of requests per second to the same host, you're burning CPU and network round-trips on handshakes alone. HTTP keep-alive and connection pooling explained: keep-alive lets you reuse a TCP connection across multiple HTTP requests, while connection pooling lets you maintain a pool of warm connections ready to go. The real cost difference is measurable, and this article shows you exactly what happens inside the socket layer when you enable or disable keep-alive, and how to measure it in your own services.

Why Keep-Alive Matters

A TCP handshake costs at least one round-trip time (RTT). In a data center with 100 microsecond RTT, that's already a 100 microsecond tax on every request. Over the network, a typical RTT is 50-200 milliseconds. If your service makes 100 requests per second to a backend, you're spending 5-20 seconds per second just on handshakes. That's not hyperbole. Add TLS negotiation (one more RTT for TLS 1.3, two for TLS 1.2), and a "simple" request to a remote HTTPS endpoint can take 100-300 milliseconds just for the ceremony before the first byte of the request even sends.

Keep-alive solves this by keeping the TCP connection open after the response. The next request reuses the same socket, skipping the handshake. Connection pooling takes this further: instead of sharing a single connection (which becomes a bottleneck under load), you maintain a pool of N warm connections, so multiple concurrent requests can proceed in parallel without waiting for each other.

How Keep-Alive Works

HTTP/1.1 made keep-alive the default. Each request includes a Connection header; the client sends Connection: close to signal "close after this response," or the server does the same. If neither side sends close, the connection stays open. The server may also send Keep-Alive: timeout=60, max=100 to tell the client "I'll keep this connection alive for up to 60 seconds and 100 requests."

At the socket level, nothing special happens. The kernel buffers the data, TCP stays established, and the client can send a new HTTP request immediately. The server reads the new request from the same socket, processes it, and sends a response. From a system calls perspective, keep-alive eliminates the socket(), connect(), and close() calls, and for TLS, it eliminates the TLS 1.3 handshake.

Measuring the Real Difference

Let's build a tiny benchmark that makes HTTP requests with and without keep-alive, measuring requests per second. This Go program starts a local server and a client that fires requests at it. The server responds with a fixed 1 KB payload. We'll measure two scenarios: one connection per request (no keep-alive), and one pool of connections with keep-alive.

package main

import (
    "fmt"
    "io"
    "net"
    "net/http"
    "sync"
    "sync/atomic"
    "time"
)

func startServer() *http.Server {
    mux := http.NewServeMux()
    mux.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
        payload := make([]byte, 1024)
        w.Write(payload)
    })

    server := &http.Server{
        Addr:           ":8765",
        Handler:        mux,
        ReadTimeout:    10 * time.Second,
        WriteTimeout:   10 * time.Second,
        MaxHeaderBytes: 1 << 20,
    }

    go func() {
        server.ListenAndServe()
    }()

    time.Sleep(100 * time.Millisecond)
    return server
}

func benchmarkNoKeepAlive(duration time.Duration, numGoroutines int) int64 {
    var count int64
    var wg sync.WaitGroup

    start := time.Now()
    for i := 0; i < numGoroutines; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for time.Since(start) < duration {
                req, _ := http.NewRequest("GET", "http://localhost:8765/test", nil)
                req.Header.Set("Connection", "close")

                client := &http.Client{
                    Transport: &http.Transport{
                        DisableKeepAlives: true,
                    },
                }
                resp, err := client.Do(req)
                if err != nil {
                    continue
                }
                io.ReadAll(resp.Body)
                resp.Body.Close()
                atomic.AddInt64(&count, 1)
            }
        }()
    }

    wg.Wait()
    return count
}

func benchmarkWithKeepAlive(duration time.Duration, numGoroutines int) int64 {
    var count int64
    var wg sync.WaitGroup

    client := &http.Client{
        Transport: &http.Transport{
            MaxIdleConns:        100,
            MaxIdleConnsPerHost: 10,
            IdleConnTimeout:     90 * time.Second,
        },
    }

    start := time.Now()
    for i := 0; i < numGoroutines; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for time.Since(start) < duration {
                req, _ := http.NewRequest("GET", "http://localhost:8765/test", nil)
                resp, err := client.Do(req)
                if err != nil {
                    continue
                }
                io.ReadAll(resp.Body)
                resp.Body.Close()
                atomic.AddInt64(&count, 1)
            }
        }()
    }

    wg.Wait()
    return count
}

func main() {
    server := startServer()
    defer server.Close()

    testDuration := 5 * time.Second
    numGoroutines := 4

    fmt.Println("Benchmarking HTTP keep-alive and connection pooling...")
    fmt.Println()

    fmt.Printf("Test duration: %v, Goroutines: %d\n", testDuration, numGoroutines)
    fmt.Println()

    fmt.Print("Running without keep-alive (Connection: close)...")
    noKeepAliveCount := benchmarkNoKeepAlive(testDuration, numGoroutines)
    fmt.Printf(" Done\n")
    fmt.Printf("Requests completed: %d\n", noKeepAliveCount)
    fmt.Printf("Requests per second: %.0f\n", float64(noKeepAliveCount)/testDuration.Seconds())
    fmt.Println()

    fmt.Print("Running with keep-alive and connection pooling...")
    withKeepAliveCount := benchmarkWithKeepAlive(testDuration, numGoroutines)
    fmt.Printf(" Done\n")
    fmt.Printf("Requests completed: %d\n", withKeepAliveCount)
    fmt.Printf("Requests per second: %.0f\n", float64(withKeepAliveCount)/testDuration.Seconds())
    fmt.Println()

    improvement := float64(withKeepAliveCount) / float64(noKeepAliveCount)
    fmt.Printf("Improvement factor: %.2fx\n", improvement)
}
Enter fullscreen mode Exit fullscreen mode

Compile and run this:

go run main.go
Enter fullscreen mode Exit fullscreen mode

On a modern laptop with a local server, you should see something like:

Benchmarking HTTP keep-alive and connection pooling...

Test duration: 5s, Goroutines: 4

Running without keep-alive (Connection: close)... Done
Requests completed: 892
Requests per second: 178

Running with keep-alive and connection pooling... Done
Requests completed: 8342
Requests per second: 1668

Improvement factor: 9.35x
Enter fullscreen mode Exit fullscreen mode

That 9x difference is real. Without keep-alive, the client spends most of its time in socket setup and teardown. With keep-alive and a small pool, requests pipeline through warm connections.

Connection Pooling Details

Connection pooling is not magic. It's a queue of established TCP connections. When a goroutine needs to make a request, it checks if a connection is available in the pool. If yes, it reuses it. If no, it opens a new one (up to a limit) or waits for one to be returned. Most HTTP client libraries handle this automatically.

In Go's net/http.Transport, key settings are:

  • MaxIdleConns: Total idle connections across all hosts. Default is 100.
  • MaxIdleConnsPerHost: Idle connections per host. Default is 2. If you're hammering one backend, increase this.
  • MaxConnsPerHost: Absolute limit of connections per host (idle + active). Default is unlimited.
  • IdleConnTimeout: How long an idle connection sits before the client closes it. Default is 90 seconds.

Under the hood, the Transport maintains a map of "http://hostname:port" to a queue of connections. When you call client.Do(), the Transport looks up that host, grabs an idle connection if available, or opens a new one. After the response is read, the connection is returned to the pool (unless you set DisableKeepAlives: true or the server sent Connection: close).

TLS and Keep-Alive

For HTTPS, keep-alive saves even more work. A TLS 1.3 handshake is one additional round-trip. TLS 1.2 is two. Without keep-alive, every request eats that cost. With keep-alive, the TLS session is cached inside the TCP connection, and subsequent requests on the same connection use the same session key. No re-negotiation.

If you're integrating a third-party authentication service or making calls to token-based security systems, the latency difference between connection pooling and no pooling can be the difference between 50 ms per request and 500 ms. That adds up quickly.

Idle Connections and Resource Limits

Idle connections consume resources: kernel socket buffers, file descriptors, and TCP control structures. On high-concurrency services, you need to balance pool size against system limits. Linux has /proc/sys/fs/file-max and per-process limits (ulimit -n). If your pool maintains 10,000 idle connections, that's 10,000 file descriptors.

The IdleConnTimeout is your safeguard. Set it to match the server's keep-alive timeout (often 60-90 seconds). If the server closes after 60 seconds and your client keeps the connection open for 120 seconds, you'll get surprise connection resets when trying to reuse a dead connection. Most robust clients detect this and transparently retry, but it's latency you could avoid.

When to Disable Keep-Alive

Keep-alive is usually right. But there are exceptions:

  • Proxies or gateways: If you're a stateless proxy passing through requests, connection pooling can mask load distribution. Some setups prefer connection-per-request to spread load across backend instances.
  • Long-lived connections that timeout: If the server aggressively closes idle connections (e.g., every 10 seconds), keeping a pool of idle connections wastes resources and causes timeouts.
  • Old HTTP/1.0 servers: Some legacy services don't support keep-alive. Set DisableKeepAlives: true in the Transport.
  • Memory-constrained environments: Embedded systems or containers with tight memory budgets may not afford pool overhead.

HTTP/2 and Beyond

HTTP/2 multiplexes multiple requests over a single connection by default. No connection pooling needed; you get stream-level concurrency on one connection. This is one reason HTTP/2 is faster for many workloads. However, if the connection is interrupted or the server closes it, you still pay for a fresh handshake.

WebSockets, by contrast, establish a persistent bidirectional connection and do not benefit from HTTP's connection pooling model; see our WebSocket article for that pattern.

Common Mistakes

Creating a new http.Client per request: Clients include a Transport with its own pool. If you do client := &http.Client{} for every request, each one starts with an empty pool. Reuse a single client across your application.

Not reading response bodies: If you ignore resp.Body, the Transport doesn't return the connection to the pool; it closes it instead. Always call io.ReadAll(resp.Body) and resp.Body.Close() or use io.Copy to drain the body.

Setting MaxIdleConnsPerHost too low: If you have 10 goroutines all hammering one endpoint and MaxIdleConnsPerHost is 2, you're thrashing the pool. Increase it to match your concurrency.

Mismatched timeouts: If your IdleConnTimeout is 90 seconds but the server closes after 60, you'll get silent connection resets. Check the server's Keep-Alive header.

FAQ

Does HTTP/2 make connection pooling irrelevant?

No. HTTP/2 multiplexes streams on a single connection, which is more efficient than pooling multiple connections. However, you still want to maintain a pool of HTTP/2 connections (even if small) to handle reconnection and geographic distribution across multiple servers.

Can I manually close an idle connection to force refresh?

Yes. Call client.CloseIdleConnections(). Useful when testing or when the backend rotates servers. But in production, let the IdleConnTimeout do the work.

What if the server doesn't send a Keep-Alive header?

In HTTP/1.1, connections are keep-alive by default unless Connection: close is sent. The absence of a Keep-Alive header does not mean the connection closes. However, some servers do close after a fixed duration anyway (often 30-60 seconds). Monitor your logs for reset errors and adjust IdleConnTimeout if needed.

Is connection pooling safe with multiple goroutines?

Yes. http.Client and Transport are goroutine-safe. Multiple goroutines can call client.Do() concurrently, and the Transport serializes access to the pool internally with mutexes.

How do I know what pool size is optimal?

Measure. Start with MaxIdleConnsPerHost equal to your expected concurrency (e.g., 20 goroutines, set it to 20). Monitor TCP connection count and request latency. If latency is stable and connection count grows to your pool size during load, you're sized right. If you're hitting MaxConnsPerHost limits and getting delays, increase the limit.

Top comments (0)