DEV Community

Mustafa Veysi Soyvural
Mustafa Veysi Soyvural

Posted on

connpool: A Zero-Alloc TCP Connection Pool for Go

The Story

Years ago, when I first needed TCP connection pooling in Go, I found fatih/pool. It was elegant, simple, and it taught me a lot about channel-based pooling design. It was a real inspiration.

But as I worked on high-throughput systems at scale, I kept running into gaps: no health checks, no idle eviction, no lifetime management, no metrics. I'd bolt these on as wrappers, and eventually realized I was maintaining a full pool implementation anyway.

So I built connpool — taking the channel-based foundation that fatih/pool popularized and adding the features that production systems actually need.

Introducing connpool

github.com/soyvural/connpool — a production-grade TCP connection pool for Go.

go get github.com/soyvural/connpool@v1.0.0
Enter fullscreen mode Exit fullscreen mode

Zero dependencies. ~370 lines of code. Zero-alloc fast path.

Quick Example

cfg := connpool.Config{
    MinSize:     5,
    MaxSize:     20,
    Increment:   2,
    IdleTimeout: 30 * time.Second,
    MaxLifetime: 5 * time.Minute,
    Ping: func(c net.Conn) error {
        c.SetReadDeadline(time.Now().Add(time.Millisecond))
        buf := make([]byte, 1)
        if _, err := c.Read(buf); err != nil {
            if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
                c.SetReadDeadline(time.Time{})
                return nil // timeout = alive
            }
            return err // dead
        }
        c.SetReadDeadline(time.Time{})
        return nil
    },
}

pool, _ := connpool.New(cfg, func() (net.Conn, error) {
    return net.DialTimeout("tcp", "localhost:6379", 5*time.Second)
}, connpool.WithName("redis-pool"))
defer pool.Stop()

conn, _ := pool.Get(ctx)
defer conn.Close() // returns to pool

// If the connection is broken:
pool.MarkUnusable(conn)
conn.Close() // destroys instead of returning
Enter fullscreen mode Exit fullscreen mode

What Makes It Different

1. Health Check on Get()

Every Get() call validates the connection before returning it. The check order is optimized — cheapest first:

Lifetime check (time comparison) → Idle check (time comparison) → Ping (network I/O)
Enter fullscreen mode Exit fullscreen mode

Time comparisons cost nanoseconds. The expensive network ping only runs if the connection passes the time-based checks. If a connection fails health check, it's discarded and the pool retries (up to 3 times) before growing.

2. Max Lifetime with Jitter

Connections have a maximum lifetime, but here's the trick: each connection gets a 10% random jitter on its expiration.

Why? Without jitter, if you create 20 connections at startup, they all expire at the exact same moment — causing a thundering herd of reconnections. Jitter spreads the expiration across time.

func (c *Config) maxLifetimeWithJitter() time.Duration {
    if c.MaxLifetime <= 0 {
        return 0
    }
    jitter := time.Duration(rand.Int64N(int64(c.MaxLifetime) / 10))
    return c.MaxLifetime + jitter
}
Enter fullscreen mode Exit fullscreen mode

This is the same pattern used by pgx and Vitess.

3. Background Evictor

Without a background evictor, stale connections only get cleaned up when someone calls Get(). If the pool is idle (no traffic), dead connections accumulate silently.

The evictor runs every 30 seconds (configurable), drains the channel, health-checks each connection, discards the stale ones, and replenishes to MinSize.

4. Zero-Alloc Fast Path

The pool uses a channel internally (inspired by fatih/pool's original design), not a mutex+slice. This gives us:

  • Natural blocking semantics (when pool is at max, Get() blocks on channel receive)
  • Non-blocking fast path via select/default
  • Zero heap allocations on the hot path
BenchmarkGetPut_Sequential    8,556,068    139.9 ns/op    0 B/op    0 allocs/op
BenchmarkGetPut_Parallel      5,075,728    245.1 ns/op    0 B/op    0 allocs/op
Enter fullscreen mode Exit fullscreen mode

5. Context-Aware Get()

Get() respects context deadlines and cancellation. If the pool is exhausted and a connection doesn't become available before the deadline, you get context.DeadlineExceeded — not a hang.

6. Comprehensive Metrics

stats := pool.Stats()
fmt.Printf("size=%d active=%d available=%d\n",
    stats.Size(), stats.Active(), stats.Available())
fmt.Printf("idle_closed=%d lifetime_closed=%d ping_failed=%d\n",
    stats.IdleClosed(), stats.LifetimeClosed(), stats.PingFailed())
fmt.Printf("wait_count=%d wait_time=%v\n",
    stats.WaitCount(), stats.WaitTime())
Enter fullscreen mode Exit fullscreen mode

Every metric you need for alerting and debugging — without pulling in a metrics library.

Benchmarks

goos: darwin
goarch: arm64
cpu: Apple M2 Pro
BenchmarkGetPut_Sequential    8,556,068    139.9 ns/op    0 B/op    0 allocs/op
BenchmarkGetPut_Parallel      5,075,728    245.1 ns/op    0 B/op    0 allocs/op
BenchmarkGetPut_WithPing        216,386   5571   ns/op   81 B/op    2 allocs/op
BenchmarkGetPut_Contended     2,405,068    478.7 ns/op    0 B/op    0 allocs/op
Enter fullscreen mode Exit fullscreen mode

The fast path (sequential, no ping) runs at 139ns with zero allocations. Even under heavy contention (8 workers fighting over 2 connections), it stays under 500ns.

Design Decisions

Decision Why
Channel over mutex+slice Natural blocking, zero-alloc select/default fast path
Lifetime jitter Prevents thundering herd reconnection storms
Health check ordering Cheapest checks first (time → time → network)
Background evictor Don't wait for Get() to clean dead connections
conn.Close() returns to pool Familiar net.Conn interface — no new API to learn

Inspired By the Best

This pool draws ideas from several production-grade systems:

Feature Where I Learned It
Channel-based pool fatih/pool
Idle timeout + max lifetime pgx, Vitess
Health check on borrow go-redis, Apache Commons Pool
Background evictor pgx, Apache Commons Pool, Vitess
Context-aware Get Vitess, pgx
MarkUnusable fatih/pool

Get Started

go get github.com/soyvural/connpool@v1.0.0
Enter fullscreen mode Exit fullscreen mode

The repo includes three working examples:

  • tcp-echo — basic pool usage
  • redis-proxy — pooling with health checks against Redis
  • load-balancer — round-robin across multiple backend pools

GitHub: soyvural/connpool


If you're working with TCP connections in Go and need something more robust than a basic pool, give connpool a try. Star the repo if you find it useful — it helps others discover it.

I'd love feedback on the API design, especially if you have use cases I haven't considered. Drop a comment or open an issue!

Top comments (0)