← Back to DevBytes

Go API Bottleneck Detection and Resolution

Understanding API Bottlenecks in Go

An API bottleneck is any point in your request-handling pipeline where throughput degrades or latency spikes, causing requests to queue, slow down, or fail under load. In Go, bottlenecks typically manifest as high tail latencies (p99/p999), increasing error rates, or CPU/memory saturation that prevents the server from handling its expected requests per second (RPS). Because Go's concurrency model makes it easy to spin up thousands of goroutines, bottlenecks often hide behind goroutine scheduling, I/O contention, or lock contention until load exposes them.

Detecting bottlenecks means systematically identifying which component—handler logic, database queries, external service calls, serialization, or even garbage collection—is consuming disproportionate time or resources. Resolution involves applying targeted fixes such as connection pooling, caching, query optimization, or architectural changes that eliminate the constrained resource.

Why Bottleneck Detection Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Ignoring bottlenecks leads to cascading failures. A single slow endpoint can exhaust connection pools, starve other endpoints of database connections, and cause goroutine leaks that eventually crash the process. In microservice architectures, a bottlenecked service becomes a contagion: upstream services time out, retries amplify the load, and the entire system degrades. Proactive detection allows you to:

Common Bottleneck Sources in Go APIs

1. Database Query Inefficiency

Missing indexes, N+1 query patterns, unoptimized ORM usage, or full table scans under the database/sql package. Each query holds a connection from the pool, and slow queries saturate it.

2. External HTTP Calls Without Timeouts

Calling downstream services with http.Client{} and no Timeout field. A hung downstream holds goroutines indefinitely, consuming memory and eventually blocking the entire server.

3. Serialization Overhead

Using encoding/json for large payloads repeatedly, or unmarshalling in hot paths without considering streaming decoders or alternative libraries like github.com/goccy/go-json.

4. Lock Contention

Shared mutable state protected by sync.Mutex that is held for too long, or frequent calls to sync.RWMutex where reads dominate but writes block readers.

5. Goroutine Leaks

Goroutines blocked on channels that will never receive a value, or launched in request handlers without a lifecycle bound to the request context.

6. Garbage Collection Pressure

Excessive allocations on the hot path, such as creating temporary slices in tight loops or boxing primitives into interfaces unnecessarily.

Detection Strategies

Profiling with pprof

Go's built-in net/http/pprof package exposes CPU, heap, goroutine, and mutex profiles over HTTP. This is the first tool to reach for when latency spikes. Enable it in your server with a dedicated profiling port (never expose this to the public internet):

package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
    "time"
)

func main() {
    // Start the profiling server on a separate port
    go func() {
        log.Println("pprof server listening on :6060")
        log.Fatal(http.ListenAndServe("localhost:6060", nil))
    }()

    // Your main API server
    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", dataHandler)

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }
    log.Fatal(srv.ListenAndServe())
}

func dataHandler(w http.ResponseWriter, r *http.Request) {
    // Simulate work
    time.Sleep(150 * time.Millisecond)
    w.Write([]byte(`{"status":"ok"}`))
}

To capture a CPU profile during a load test, run:

# 30-second CPU profile
curl -o cpu.prof "http://localhost:6060/debug/pprof/profile?seconds=30"

# Heap profile
curl -o heap.prof "http://localhost:6060/debug/pprof/heap"

# Goroutine profile
curl -o goroutine.prof "http://localhost:6060/debug/pprof/goroutine"

# Analyze interactively
go tool pprof -http=:8081 cpu.prof

The flame graph view in pprof immediately shows which functions consume the most CPU time. For API bottlenecks, focus on functions that appear disproportionately in the profile relative to their expected cost—especially I/O wait that shows up as CPU time in syscalls.

Tracing with runtime/trace

While pprof shows aggregate statistics, the runtime/trace package captures precise event timelines: goroutine scheduling, network polling, garbage collection pauses, and syscall durations. This reveals latency bubbles that pprof might average away:

package main

import (
    "io"
    "log"
    "net/http"
    "os"
    "runtime/trace"
)

func main() {
    // Create trace file
    f, err := os.Create("trace.out")
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()

    if err := trace.Start(f); err != nil {
        log.Fatal(err)
    }
    defer trace.Stop()

    // Run your API server and generate load...
    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
        // Simulate DB call and serialization
        data := generateLargeJSON()
        w.Header().Set("Content-Type", "application/json")
        io.WriteString(w, data)
    })

    log.Fatal(http.ListenAndServe(":8080", mux))
}

func generateLargeJSON() string {
    // Allocates heavily — visible in trace as GC pauses
    items := make([]map[string]interface{}, 1000)
    for i := range items {
        items[i] = map[string]interface{}{
            "id":   i,
            "name": "example_item_name",
            "data": "repeated_payload_content",
        }
    }
    // Return a simplified representation
    return `{"count":1000,"status":"ok"}`
}

View the trace with go tool trace trace.out. The timeline shows exactly when each goroutine was scheduled, blocked, or descheduled. Look for long periods of "syscall" or "IO wait" that correlate with request handling goroutines.

Middleware-Based Latency Tracking

Instrument your handlers with a middleware that records duration histograms. This gives you per-endpoint visibility and catches slow endpoints before they saturate the server:

package middleware

import (
    "log"
    "net/http"
    "time"
)

// LatencyTracker wraps an http.Handler and logs slow requests.
func LatencyTracker(slowThreshold time.Duration, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Wrap ResponseWriter to capture status code
        rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

        next.ServeHTTP(rw, r)

        elapsed := time.Since(start)
        if elapsed > slowThreshold {
            log.Printf(
                "SLOW REQUEST | method=%s path=%s status=%d duration=%v",
                r.Method, r.URL.Path, rw.statusCode, elapsed,
            )
        }
    })
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

Combine this with a Prometheus histogram for production-grade observability:

package middleware

import (
    "net/http"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    requestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "Duration of HTTP requests in seconds",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path", "status"},
    )
)

func PrometheusMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

        next.ServeHTTP(rw, r)

        requestDuration.WithLabelValues(
            r.Method, r.URL.Path, http.StatusText(rw.statusCode),
        ).Observe(time.Since(start).Seconds())
    })
}

Database Query Profiling

For SQL bottlenecks, wrap your database calls with timing and expose query plans. Use database/sql's built-in connection pool metrics:

package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "time"

    _ "github.com/lib/pq"
)

func monitorDBPool(db *sql.DB) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        stats := db.Stats()
        log.Printf(
            "DB Pool: open=%d in-use=%d idle=%d wait=%d max-idle-closed=%d max-lifetime-closed=%d",
            stats.OpenConnections,
            stats.InUse,
            stats.Idle,
            stats.WaitCount,
            stats.MaxIdleClosed,
            stats.MaxLifetimeClosed,
        )
        if stats.WaitCount > 0 {
            log.Printf("WARNING: %d connections waited — pool may be undersized", stats.WaitCount)
        }
    }
}

func queryWithTiming(ctx context.Context, db *sql.DB, query string, args ...interface{}) error {
    start := time.Now()
    defer func() {
        elapsed := time.Since(start)
        if elapsed > 100*time.Millisecond {
            log.Printf("SLOW QUERY | duration=%v query=%s", elapsed, query)
        }
    }()

    rows, err := db.QueryContext(ctx, query, args...)
    if err != nil {
        return fmt.Errorf("query failed: %w", err)
    }
    defer rows.Close()

    for rows.Next() {
        // process rows
    }
    return rows.Err()
}

Resolution Techniques

1. Implement Connection Pooling and Sizing

The most common bottleneck in Go APIs is an undersized or misconfigured database connection pool. database/sql provides pooling by default, but you must tune it:

db, err := sql.Open("postgres", dsn)
if err != nil {
    log.Fatal(err)
}

// Tune pool parameters based on your workload and database limits
db.SetMaxOpenConns(25)                   // <= database max_connections minus headroom
db.SetMaxIdleConns(10)                   // keep idle connections warm
db.SetConnMaxLifetime(5 * time.Minute)   // rotate connections to avoid stale ones
db.SetConnMaxIdleTime(2 * time.Minute)   // release idle connections promptly

// Verify connectivity
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
    log.Fatalf("database unreachable: %v", err)
}

For HTTP downstream calls, configure the http.Client transport pool explicitly—never use the default client in production:

var httpClient = &http.Client{
    Timeout: 10 * time.Second, // overall request timeout
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        DisableCompression:  false,
    },
}

2. Add Context-Aware Timeouts and Cancellation

Every I/O boundary must respect the request's context. This prevents goroutine leaks and ensures resources are released promptly when clients disconnect:

func dataHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Use a sub-context with a stricter timeout for the DB call
    queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    var result string
    err := db.QueryRowContext(queryCtx, "SELECT data FROM items WHERE id = $1", itemID).Scan(&result)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "database query timed out", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // Downstream call with context
    req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.external.service/data", nil)
    resp, err := httpClient.Do(req)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            // Client disconnected — log and abort
            return
        }
        http.Error(w, "downstream service unavailable", http.StatusFailedDependency)
        return
    }
    defer resp.Body.Close()
    // process response
}

3. Implement Caching Layers

For read-heavy endpoints with stable data, add an in-memory cache to reduce database pressure. Use github.com/patrickmn/go-cache or a library like github.com/dgraph-io/ristretto for high-performance caching with configurable eviction policies:

import (
    "sync"
    "time"
)

// Simple thread-safe cache with expiration
type Cache struct {
    mu    sync.RWMutex
    items map[string]cacheEntry
}

type cacheEntry struct {
    value      interface{}
    expiresAt  time.Time
}

func NewCache(cleanupInterval time.Duration) *Cache {
    c := &Cache{items: make(map[string]cacheEntry)}
    go c.cleanupLoop(cleanupInterval)
    return c
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    entry, exists := c.items[key]
    if !exists || time.Now().After(entry.expiresAt) {
        return nil, false
    }
    return entry.value, true
}

func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = cacheEntry{
        value:     value,
        expiresAt: time.Now().Add(ttl),
    }
}

func (c *Cache) cleanupLoop(interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    for range ticker.C {
        c.mu.Lock()
        now := time.Now()
        for key, entry := range c.items {
            if now.After(entry.expiresAt) {
                delete(c.items, key)
            }
        }
        c.mu.Unlock()
    }
}

// Usage in handler
var itemCache = NewCache(30 * time.Second)

func getItemHandler(w http.ResponseWriter, r *http.Request) {
    itemID := r.URL.Query().Get("id")

    // Check cache first
    if cached, ok := itemCache.Get(itemID); ok {
        w.Header().Set("X-Cache", "HIT")
        json.NewEncoder(w).Encode(cached)
        return
    }

    // Cache miss — query database
    var item Item
    err := db.QueryRowContext(r.Context(), "SELECT id, name FROM items WHERE id = $1", itemID).
        Scan(&item.ID, &item.Name)
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    itemCache.Set(itemID, item, 5*time.Minute)
    w.Header().Set("X-Cache", "MISS")
    json.NewEncoder(w).Encode(item)
}

4. Optimize Serialization

If profiling shows encoding/json dominating CPU, consider these alternatives:

import (
    "io"
    "net/http"

    "github.com/goccy/go-json" // drop-in replacement, faster
)

// Use a streaming encoder instead of marshalling entire payload into memory
func streamHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")

    // Stream JSON directly to the response writer
    encoder := json.NewEncoder(w)
    for _, item := range queryResults {
        if err := encoder.Encode(item); err != nil {
            log.Printf("encode error: %v", err)
            return
        }
    }
}

// For large payloads, avoid json.Marshal into a byte slice
func efficientHandler(w http.ResponseWriter, r *http.Request) {
    // BAD: allocates full byte slice
    // data, _ := json.Marshal(largeStruct)
    // w.Write(data)

    // GOOD: stream directly
    encoder := json.NewEncoder(w)
    encoder.Encode(largeStruct)
}

5. Reduce Lock Contention

If mutex profiles reveal contention, restructure your synchronization patterns:

import "sync"

// BAD: single mutex protecting entire map, held during I/O
type BadService struct {
    mu    sync.Mutex
    data  map[string]int
}

func (s *BadService) Process(key string) int {
    s.mu.Lock()
    defer s.mu.Unlock()
    // I/O inside critical section — locks held too long
    time.Sleep(100 * time.Millisecond)
    return s.data[key]
}

// GOOD: fine-grained locking with sync.Map for read-heavy workloads
type GoodService struct {
    data sync.Map
}

func (s *GoodService) Process(key string) (int, bool) {
    // sync.Map uses lock-free reads under the hood
    val, ok := s.data.Load(key)
    if !ok {
        return 0, false
    }
    return val.(int), true
}

// BETTER: sharded map for balanced read/write workloads
type ShardedService struct {
    shards [16]shard
}

type shard struct {
    mu   sync.RWMutex
    data map[string]int
}

func (s *ShardedService) shardKey(key string) int {
    h := fnv.New32a()
    h.Write([]byte(key))
    return int(h.Sum32()) % len(s.shards)
}

func (s *ShardedService) Get(key string) (int, bool) {
    idx := s.shardKey(key)
    s.shards[idx].mu.RLock()
    defer s.shards[idx].mu.RUnlock()
    val, ok := s.shards[idx].data[key]
    return val, ok
}

6. Prevent Goroutine Leaks

Always bind goroutine lifetimes to the request context or a parent context that will be cancelled:

func processRequest(ctx context.Context, items []string) error {
    results := make(chan string, len(items))

    for _, item := range items {
        go func(it string) {
            // Respect context cancellation
            select {
            case <-ctx.Done():
                return // clean up immediately
            case result := <-fetchFromExternal(ctx, it):
                select {
                case results <- result:
                case <-ctx.Done():
                }
            }
        }(item)
    }

    // Wait with context awareness
    for i := 0; i < len(items); i++ {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case res := <-results:
            log.Printf("received: %s", res)
        }
    }
    return nil
}

func fetchFromExternal(ctx context.Context, item string) <-chan string {
    ch := make(chan string, 1)
    go func() {
        defer close(ch)
        req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/"+item, nil)
        resp, err := httpClient.Do(req)
        if err != nil {
            return
        }
        defer resp.Body.Close()
        body, _ := io.ReadAll(resp.Body)
        ch <- string(body)
    }()
    return ch
}

7. Reduce Garbage Collection Pressure

Use sync.Pool for frequently allocated temporary objects, prefer value types over pointer types on the stack, and pre-allocate slices when the size is known:

import "sync"

var bufferPool = sync.Pool{
    New: func() interface{} {
        return make([]byte, 0, 4096)
    },
}

func processLargePayload(data []byte) []byte {
    // Acquire buffer from pool
    buf := bufferPool.Get().([]byte)
    defer func() {
        // Reset and return to pool
        buf = buf[:0]
        bufferPool.Put(buf)
    }()

    // Process using pooled buffer instead of allocating new slices
    buf = append(buf, data...)
    // ... transformation logic
    result := make([]byte, len(buf))
    copy(result, buf)
    return result
}

// Pre-allocate slices when size is predictable
func queryToStructs(ctx context.Context, db *sql.DB) ([]Item, error) {
    rows, err := db.QueryContext(ctx, "SELECT id, name FROM items LIMIT 100")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    // Pre-allocate slice capacity to avoid incremental growth allocations
    items := make([]Item, 0, 100)
    for rows.Next() {
        var item Item
        if err := rows.Scan(&item.ID, &item.Name); err != nil {
            return nil, err
        }
        items = append(items, item)
    }
    return items, rows.Err()
}

Best Practices

import "golang.org/x/sync/semaphore"

// Limit concurrent downstream calls to 50
var downstreamSem = semaphore.NewWeighted(50)

func fanOutHandler(ctx context.Context, ids []string) error {
    for _, id := range ids {
        if err := downstreamSem.Acquire(ctx, 1); err != nil {
            return err
        }
        go func(itemID string) {
            defer downstreamSem.Release(1)
            callDownstream(ctx, itemID)
        }(id)
    }
    return nil
}

Conclusion

Bottleneck detection and resolution in Go APIs is a continuous cycle: instrument with profiling endpoints and middleware metrics, load test to expose constraints, analyze profiles and traces to pinpoint root causes, apply targeted fixes (pool sizing, caching, context timeouts, lock restructuring), and verify improvements with repeatable benchmarks. The Go ecosystem provides exceptional tooling—pprof, runtime/trace, and Prometheus client libraries—that makes this cycle fast and precise. The key is to build observability into your service from day one, treat every I/O boundary as a potential bottleneck, and always let profiles guide your optimization effort rather than intuition. By combining proactive instrumentation with the resolution patterns outlined here, you can maintain APIs that deliver consistent low latency even as load grows by an order of magnitude.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles