DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a High-Performance REST API in Go with Connection Pooling

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:

  • MaxIdleConns defaults to 100
  • MaxOpenConns on database/sql is unlimited by default
  • MaxIdleConnsPerHost on 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,
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

What each setting does:

  • SetMaxOpenConns(25): hard cap on concurrent connections. Size this relative to your Postgres max_connections divided by the number of app instances.
  • SetMaxIdleConns(10): keep this below MaxOpenConns. 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Baseline first: run SELECT count(*) FROM pg_stat_activity under peak load before any tuning. That's your real connection usage.
  2. Target 80% of max_connections spread across all instances. If max_connections = 100 and you run 4 app instances, SetMaxOpenConns(20) per instance is a safe starting point.
  3. 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,
)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Set MaxIdleConnsPerHost on your HTTP transport — the default of 2 will serialize outbound requests to any single host at scale.
  2. Set MaxOpenConns on 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)