Most Go HTTP servers work fine until they hit real traffic. The typical failure mode: everything passes local testing, then under load you see timeouts, "too many open files" errors, or database exhaustion. The fix almost always involves tuning connection pooling — something the standard library and popular ORMs configure poorly by default.
This guide covers practical connection pooling for both the HTTP server side and the database side, with working code you can drop into production.
Why Default Settings Will Hurt You
net/http's DefaultTransport and sql.Open() both use conservative defaults designed for small workloads:
-
MaxIdleConnsdefaults to 100 -
MaxOpenConnsondatabase/sqlis unlimited by default -
MaxIdleConnsPerHoston the HTTP transport defaults to 2
The unlimited open connections setting is the dangerous one. Under sustained load, your app will happily open thousands of Postgres connections until either the database refuses new ones (max_connections exhausted) or the OS runs out of file descriptors. The MaxIdleConnsPerHost default of 2 is the other trap: even with MaxIdleConns: 200, you'll reuse at most 2 connections per upstream domain.
Configuring the Outbound HTTP Client
If your API makes outbound HTTP calls — to third-party APIs or internal services — configure http.Transport explicitly:
package main
import (
"net"
"net/http"
"time"
)
func newHTTPClient() *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50,
MaxConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
}
}
MaxIdleConnsPerHost: 50 is the key change from defaults. If you call a single upstream service at scale — say, a payment gateway or an LLM API — this single setting can cut your p99 latency by 30-50% by eliminating the TCP handshake on every request.
Configuring database/sql Connection Pooling
The database/sql pool is simpler to configure but needs explicit limits:
package main
import (
"database/sql"
"fmt"
"log"
"time"
_ "github.com/lib/pq"
)
func newDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
// These four settings should always be set explicitly.
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping: %w", err)
}
log.Println("database pool ready")
return db, nil
}
What each setting does:
-
SetMaxOpenConns(25): hard cap on concurrent connections. Size this relative to your Postgresmax_connectionsdivided by the number of app instances. -
SetMaxIdleConns(10): keep this belowMaxOpenConns. Idle connections consume memory on the database side too. -
SetConnMaxLifetime(5m): force connection recycling. Prevents stale connections after network events or database restarts. -
SetConnMaxIdleTime(2m): close connections that have been idle too long. Helps when traffic is bursty and you want to give connections back to the pool promptly.
Wiring It Into a Production Server
Here's a minimal but production-safe server structure that uses both pools and handles graceful shutdown:
package main
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
type server struct {
db *sql.DB
client *http.Client
mux *http.ServeMux
}
func newServer(db *sql.DB) *server {
s := &server{
db: db,
client: newHTTPClient(),
mux: http.NewServeMux(),
}
s.routes()
return s
}
func (s *server) routes() {
s.mux.HandleFunc("/health", s.handleHealth)
}
func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
if err := s.db.PingContext(r.Context()); err != nil {
http.Error(w, "db unreachable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
func main() {
db, err := newDB(os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("db: %v", err)
}
defer db.Close()
srv := &http.Server{
Addr: ":8080",
Handler: newServer(db).mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Println("listening on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("serve: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("shutdown: %v", err)
}
}
The server-level timeouts (ReadTimeout, WriteTimeout, IdleTimeout) are separate from pool settings but equally important. Without them, a slow client can hold a goroutine and a database connection open indefinitely.
How to Size Your Pool in Practice
There's no universal formula, but here's a practical approach:
-
Baseline first: run
SELECT count(*) FROM pg_stat_activityunder peak load before any tuning. That's your real connection usage. -
Target 80% of
max_connectionsspread across all instances. Ifmax_connections = 100and you run 4 app instances,SetMaxOpenConns(20)per instance is a safe starting point. -
Watch
db.Stats()in production. Expose these as metrics:
stats := db.Stats()
log.Printf("pool open=%d idle=%d in_use=%d wait_count=%d wait_duration=%s",
stats.OpenConnections,
stats.Idle,
stats.InUse,
stats.WaitCount,
stats.WaitDuration,
)
A rising WaitCount means the pool is too small. A rising idle count with no wait means it's oversized and wasting memory on both sides. Tune from data, not guesses.
Security-focused teams track database connection settings as an availability concern — unbound pools are a real DoS surface. Covering this kind of exposure is part of what goes into security hardening checklists.
The Takeaway
Go's standard library gives you everything you need for connection pooling, but none of the defaults are production-ready. The two highest-impact changes:
- Set
MaxIdleConnsPerHoston your HTTP transport — the default of 2 will serialize outbound requests to any single host at scale. - Set
MaxOpenConnson your DB pool — the default of unlimited will exhaust your database under load.
Get those two right first, then measure db.Stats() and tune from there. Everything else — timeouts, idle eviction, graceful shutdown — builds on top of a correctly-bounded pool.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)