DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a high-performance REST API in Go with connection pooling

Every Go developer has hit the same wall: an API that works perfectly under load testing at 50 req/s starts throwing too many connections errors the moment real traffic shows up. The culprit is almost always the same — no connection pooling, or misconfigured pooling that leaves performance on the table. Here's how to do it right.

The problem with naive database connections

The simplest Go API opens a new database connection per request. It works fine until it doesn't:

func getUser(w http.ResponseWriter, r *http.Request) {
    // Don't do this — opens a new connection on every single request
    db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        http.Error(w, "db error", 500)
        return
    }
    defer db.Close()

    var user User
    err = db.QueryRowContext(r.Context(), "SELECT id, email FROM users WHERE id = $1",
        r.PathValue("id")).Scan(&user.ID, &user.Email)
    if err != nil {
        http.Error(w, "not found", 404)
        return
    }
    json.NewEncoder(w).Encode(user)
}
Enter fullscreen mode Exit fullscreen mode

Each call to sql.Open followed by db.Close creates and tears down a TCP connection to PostgreSQL. At 100 req/s that's 100 TCP handshakes per second. PostgreSQL's default max_connections is 100 — you'll hit the ceiling fast.

Setting up a properly configured connection pool

The fix is to create a single *sql.DB instance at startup and reuse it across all requests. database/sql maintains a pool internally; you just need to configure it correctly.

package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "log/slog"
    "net/http"
    "os"
    "time"

    _ "github.com/lib/pq"
)

type App struct {
    db *sql.DB
}

func NewApp(databaseURL string) (*App, error) {
    db, err := sql.Open("postgres", databaseURL)
    if err != nil {
        return nil, err
    }

    // Core pool settings — tune these for your workload
    db.SetMaxOpenConns(25)                  // max simultaneous connections
    db.SetMaxIdleConns(10)                  // connections kept alive when idle
    db.SetConnMaxLifetime(5 * time.Minute)  // force rotation to avoid stale conns
    db.SetConnMaxIdleTime(1 * time.Minute)  // close idle conns after 1 min

    // Verify the pool is healthy before accepting traffic
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := db.PingContext(ctx); err != nil {
        return nil, err
    }

    return &App{db: db}, nil
}

func main() {
    app, err := NewApp(os.Getenv("DATABASE_URL"))
    if err != nil {
        slog.Error("failed to connect to database", "error", err)
        os.Exit(1)
    }

    mux := http.NewServeMux()
    mux.HandleFunc("GET /users/{id}", app.getUser)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    slog.Info("starting server", "addr", server.Addr)
    if err := server.ListenAndServe(); err != nil {
        slog.Error("server error", "error", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The four Set* calls do the heavy lifting. MaxOpenConns is your safety valve against overwhelming PostgreSQL. MaxIdleConns keeps a pool of warm connections ready for bursts. ConnMaxLifetime prevents you from hitting issues with connections that have gone stale through a load balancer or firewall timeout.

Getting the numbers right

The default values for database/sql are surprising: MaxOpenConns defaults to unlimited (0), which means under spike traffic your API will hammer the database with hundreds of simultaneous connections. Start with these rules of thumb, then measure:

  • MaxOpenConns: number of API server instances × expected concurrent requests per instance. For a single server handling 100 concurrent requests, set this between 20–30.
  • MaxIdleConns: should be ≤ MaxOpenConns. Setting it higher wastes memory on connections that will never be used.
  • ConnMaxLifetime: 5 minutes is safe for most environments. If you're behind an AWS RDS proxy, 3 minutes works better.

You can expose pool stats via a health endpoint to observe what's happening:

func (a *App) healthHandler(w http.ResponseWriter, r *http.Request) {
    stats := a.db.Stats()
    status := map[string]any{
        "open_connections":    stats.OpenConnections,
        "in_use":              stats.InUse,
        "idle":                stats.Idle,
        "wait_count":          stats.WaitCount,
        "wait_duration_ms":    stats.WaitDuration.Milliseconds(),
        "max_idle_closed":     stats.MaxIdleClosed,
        "max_lifetime_closed": stats.MaxLifetimeClosed,
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(status)
}
Enter fullscreen mode Exit fullscreen mode

WaitCount is the most important metric here. If it's non-zero and growing, your pool is undersized — requests are queuing up waiting for a free connection. MaxIdleClosed growing quickly means MaxIdleConns is too low for your traffic pattern.

Query patterns that won't kill your performance

Connection pooling solves the connection problem, but slow queries will still choke your API. A few patterns that matter in production:

Always pass context through to database calls. This lets you cancel queries when the client disconnects, freeing up the connection faster:

func (a *App) getUser(w http.ResponseWriter, r *http.Request) {
    userID := r.PathValue("id")

    // r.Context() propagates cancellation from the HTTP layer to the DB query
    var user User
    err := a.db.QueryRowContext(r.Context(),
        "SELECT id, email, created_at FROM users WHERE id = $1", userID,
    ).Scan(&user.ID, &user.Email, &user.CreatedAt)

    if err == sql.ErrNoRows {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    if err != nil {
        slog.ErrorContext(r.Context(), "query failed", "error", err, "user_id", userID)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}
Enter fullscreen mode Exit fullscreen mode

For write-heavy endpoints, use db.BeginTx with an explicit transaction rather than relying on individual statement autocommit. This holds the connection for the duration of the transaction and gives you rollback semantics.

Observability and security considerations

A few things that often get skipped in tutorials but matter in production:

Connection string security: never construct DATABASE_URL by concatenating user input. Use environment variables, and if you're running on Kubernetes, mount them from secrets — not from a ConfigMap. For a practical checklist on securing API infrastructure, the hardening checklists at AYI NEDJIMI Consultants cover common misconfigurations including database exposure.

Prepared statements: database/sql prepares and caches statements automatically when you use QueryContext with parameterized queries. This gives you implicit protection against SQL injection and slightly better performance on repeated queries.

Timeout hygiene: set a QueryContext timeout tighter than your HTTP write timeout. If your write timeout is 10s and a query runs for 12s, the client already gave up — but you're still holding a connection. Use context.WithTimeout inside the handler to enforce a database-level deadline.

The takeaway

High-performance Go APIs are not primarily about clever algorithms — they're about resource management. A single, properly configured *sql.DB instance with realistic pool bounds will handle 10x more traffic than the naive per-request approach, with lower latency and fewer cascading failures under load.

The settings aren't magic: MaxOpenConns(25), MaxIdleConns(10), ConnMaxLifetime(5 * time.Minute). Start there, add the health endpoint, watch WaitCount under load, and adjust up or down based on what you actually observe.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)