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
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
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)
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
}
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
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())
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
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
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
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)